Add B9 Price Statistics deepening (5 indicators) (#197)
Deepens the **Price Statistics** family (B9) with five rolling-statistics indicators (447 -> 452): - **ShannonEntropy** — Shannon entropy of a binned rolling value distribution. - **SampleEntropy** — Richman-Moorman sample entropy (regularity/complexity of a window). - **KendallTau** — Kendall rank correlation (tau-b) over paired observations (pairwise; distinct from Pearson/Spearman). - **JarqueBera** — Jarque-Bera normality test statistic over a rolling window. - **RollingMinMaxScaler** — maps the latest value to 0..1 over a rolling window. All scalar f64 input except KendallTau (pairwise). Multi-arg scalars (Shannon/Sample entropy) use hand-written Python/Node bindings + the variadic wasm macro; KendallTau uses the pair macros. Verified locally: 3668 core lib + 410 doc tests, clippy clean, 527 node tests, 871 pytest, counter 452.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
//! Jarque-Bera — a normality-test statistic on a rolling window.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Jarque-Bera — the Jarque-Bera test statistic measuring how far a window's
|
||||
/// distribution departs from normal, via its **skewness** and **excess
|
||||
/// kurtosis**.
|
||||
///
|
||||
/// ```text
|
||||
/// S = skewness = m3 / m2^(3/2)
|
||||
/// K = excess kurtosis = m4 / m2² − 3
|
||||
/// JB = (period / 6) · ( S² + K²/4 )
|
||||
/// ```
|
||||
///
|
||||
/// where `m2`, `m3`, `m4` are the second, third and fourth central moments of the
|
||||
/// window. A perfectly normal sample has zero skew and zero excess kurtosis, so
|
||||
/// `JB = 0`; the statistic grows as the distribution becomes asymmetric (non-zero
|
||||
/// skew) or fat- or thin-tailed (non-zero excess kurtosis). Under the null of
|
||||
/// normality `JB` is asymptotically χ² with two degrees of freedom, so values
|
||||
/// above roughly `6` reject normality at the 95% level — a useful streaming flag
|
||||
/// for fat-tail / crash-risk regimes in a return series.
|
||||
///
|
||||
/// The statistic is `≥ 0`. A degenerate window with zero variance (`m2 == 0`)
|
||||
/// returns `0`. The first value lands after `period` inputs; each `update`
|
||||
/// recomputes the four moments over the window in O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, JarqueBera};
|
||||
///
|
||||
/// let mut indicator = JarqueBera::new(50).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update((f64::from(i) * 0.3).sin());
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JarqueBera {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl JarqueBera {
|
||||
/// Construct a rolling Jarque-Bera over `period` values.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::InvalidPeriod`] if `period < 4` (the statistic is degenerate on
|
||||
/// fewer than four points).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period < 4 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "Jarque-Bera needs period >= 4",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
let n = self.period as f64;
|
||||
let mean = self.window.iter().sum::<f64>() / n;
|
||||
let mut m2 = 0.0;
|
||||
let mut m3 = 0.0;
|
||||
let mut m4 = 0.0;
|
||||
for &v in &self.window {
|
||||
let d = v - mean;
|
||||
let d2 = d * d;
|
||||
m2 += d2;
|
||||
m3 += d2 * d;
|
||||
m4 += d2 * d2;
|
||||
}
|
||||
m2 /= n;
|
||||
m3 /= n;
|
||||
m4 /= n;
|
||||
if m2 == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let skew = m3 / m2.powf(1.5);
|
||||
let excess_kurt = m4 / (m2 * m2) - 3.0;
|
||||
(n / 6.0) * (skew * skew + excess_kurt * excess_kurt / 4.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for JarqueBera {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let out = self.compute();
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"JarqueBera"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_period() {
|
||||
assert!(matches!(JarqueBera::new(0), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
JarqueBera::new(3),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(JarqueBera::new(4).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let jb = JarqueBera::new(50).unwrap();
|
||||
assert_eq!(jb.period(), 50);
|
||||
assert_eq!(jb.warmup_period(), 50);
|
||||
assert_eq!(jb.name(), "JarqueBera");
|
||||
assert!(!jb.is_ready());
|
||||
assert_eq!(jb.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut jb = JarqueBera::new(4).unwrap();
|
||||
let out = jb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_window_is_zero() {
|
||||
let mut jb = JarqueBera::new(8).unwrap();
|
||||
let last = jb.batch(&[5.0; 12]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_non_negative() {
|
||||
let mut jb = JarqueBera::new(30).unwrap();
|
||||
for v in jb
|
||||
.batch(
|
||||
&(0..200)
|
||||
.map(|i| (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!(v >= 0.0, "JB must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skewed_window_exceeds_symmetric() {
|
||||
// A symmetric window vs. one with a heavy outlier (high skew + kurtosis).
|
||||
let symmetric: Vec<f64> = vec![-3.0, -1.0, 0.0, 1.0, 3.0, -2.0, 2.0, 0.0];
|
||||
let skewed: Vec<f64> = vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 20.0];
|
||||
let jb_sym = JarqueBera::new(8)
|
||||
.unwrap()
|
||||
.batch(&symmetric)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
let jb_skew = JarqueBera::new(8)
|
||||
.unwrap()
|
||||
.batch(&skewed)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(
|
||||
jb_skew > jb_sym,
|
||||
"skewed ({jb_skew}) should exceed symmetric ({jb_sym})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut jb = JarqueBera::new(4).unwrap();
|
||||
let ready = jb
|
||||
.batch(&[1.0, 2.0, 3.0, 5.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(jb.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut jb = JarqueBera::new(4).unwrap();
|
||||
jb.batch(&[1.0, 2.0, 3.0, 5.0]);
|
||||
assert!(jb.is_ready());
|
||||
jb.reset();
|
||||
assert!(!jb.is_ready());
|
||||
assert_eq!(jb.value(), None);
|
||||
assert_eq!(jb.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = JarqueBera::new(30).unwrap().batch(&xs);
|
||||
let mut b = JarqueBera::new(30).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Kendall's tau-b — rank correlation by concordant vs. discordant pairs.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// `+1` / `0` / `-1` sign of `a − b`.
|
||||
fn sign(a: f64, b: f64) -> i32 {
|
||||
if a > b {
|
||||
1
|
||||
} else if a < b {
|
||||
-1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Kendall's tau-b — a rank correlation between two synchronised series based on
|
||||
/// the balance of **concordant** and **discordant** pairs, with a tie correction.
|
||||
///
|
||||
/// ```text
|
||||
/// over all pairs (i < j) in the window:
|
||||
/// concordant if (x_j − x_i) and (y_j − y_i) share a sign
|
||||
/// discordant if they have opposite signs
|
||||
/// tie_x / tie_y if the respective difference is zero
|
||||
/// n0 = N(N−1)/2
|
||||
/// tau_b = (n_concordant − n_discordant) / sqrt((n0 − tie_x)(n0 − tie_y))
|
||||
/// ```
|
||||
///
|
||||
/// Where [`PearsonCorrelation`](crate::PearsonCorrelation) measures *linear*
|
||||
/// co-movement and [`SpearmanCorrelation`](crate::SpearmanCorrelation) correlates
|
||||
/// ranks via their differences, Kendall's tau counts how often the two series move
|
||||
/// the **same direction** between every pair of observations. It is the most
|
||||
/// robust of the three to outliers and to non-linear-but-monotonic
|
||||
/// relationships, and the tau-b form corrects for ties so repeated values do not
|
||||
/// bias it. The output is in `[−1, +1]`: `+1` perfectly concordant, `−1`
|
||||
/// perfectly discordant, `0` no monotonic association.
|
||||
///
|
||||
/// The window holds the last `period` pairs and is recomputed each bar in
|
||||
/// O(`period²`). A window with no untied pairs on one side returns `0`. The first
|
||||
/// value lands after `period` inputs.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, KendallTau};
|
||||
///
|
||||
/// let mut indicator = KendallTau::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let x = f64::from(i);
|
||||
/// last = indicator.update((x, 2.0 * x)); // perfectly concordant
|
||||
/// }
|
||||
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KendallTau {
|
||||
period: usize,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl KendallTau {
|
||||
/// Construct a rolling Kendall's tau-b over `period` pairs.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a correlation needs at
|
||||
/// least two pairs).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "Kendall tau needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of pairs.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
let pairs: Vec<(f64, f64)> = self.window.iter().copied().collect();
|
||||
let len = pairs.len();
|
||||
let mut concordant: i64 = 0;
|
||||
let mut discordant: i64 = 0;
|
||||
let mut tie_x: i64 = 0;
|
||||
let mut tie_y: i64 = 0;
|
||||
for i in 0..len {
|
||||
for j in (i + 1)..len {
|
||||
let sx = sign(pairs[j].0, pairs[i].0);
|
||||
let sy = sign(pairs[j].1, pairs[i].1);
|
||||
if sx == 0 {
|
||||
tie_x += 1;
|
||||
}
|
||||
if sy == 0 {
|
||||
tie_y += 1;
|
||||
}
|
||||
let prod = sx * sy;
|
||||
if prod > 0 {
|
||||
concordant += 1;
|
||||
} else if prod < 0 {
|
||||
discordant += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let n0 = (len * (len - 1) / 2) as f64;
|
||||
let denom = ((n0 - tie_x as f64) * (n0 - tie_y as f64)).sqrt();
|
||||
if denom == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
((concordant - discordant) as f64 / denom).clamp(-1.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for KendallTau {
|
||||
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;
|
||||
}
|
||||
let out = self.compute();
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"KendallTau"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(matches!(
|
||||
KendallTau::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(KendallTau::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let k = KendallTau::new(20).unwrap();
|
||||
assert_eq!(k.period(), 20);
|
||||
assert_eq!(k.warmup_period(), 20);
|
||||
assert_eq!(k.name(), "KendallTau");
|
||||
assert!(!k.is_ready());
|
||||
assert_eq!(k.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut k = KendallTau::new(4).unwrap();
|
||||
let out = k.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0), (5.0, 5.0)]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotone_increasing_is_one() {
|
||||
let pairs: Vec<(f64, f64)> = (0..20)
|
||||
.map(|i| (f64::from(i), 2.0 * f64::from(i) + 1.0))
|
||||
.collect();
|
||||
let last = KendallTau::new(10)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotone_decreasing_is_minus_one() {
|
||||
let pairs: Vec<(f64, f64)> = (0..20)
|
||||
.map(|i| (f64::from(i), -3.0 * f64::from(i)))
|
||||
.collect();
|
||||
let last = KendallTau::new(10)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_channel_yields_zero() {
|
||||
// y constant -> every y-difference is a tie -> denom 0 -> 0.
|
||||
let pairs: Vec<(f64, f64)> = (0..20).map(|i| (f64::from(i), 7.0)).collect();
|
||||
let last = KendallTau::new(8)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let pairs: Vec<(f64, f64)> = (0..80)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
(100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0)
|
||||
})
|
||||
.collect();
|
||||
for v in KendallTau::new(20)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((-1.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut k = KendallTau::new(4).unwrap();
|
||||
k.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0)]);
|
||||
assert!(k.is_ready());
|
||||
k.reset();
|
||||
assert!(!k.is_ready());
|
||||
assert_eq!(k.value(), None);
|
||||
assert_eq!(k.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 = KendallTau::new(14).unwrap().batch(&pairs);
|
||||
let mut b = KendallTau::new(14).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ties_are_corrected() {
|
||||
// Tied x values (points 0 and 1) and tied y values (points 1 and 2)
|
||||
// exercise the tie_x / tie_y correction counters.
|
||||
let mut k = KendallTau::new(4).unwrap();
|
||||
assert_eq!(k.update((1.0, 1.0)), None);
|
||||
assert_eq!(k.update((1.0, 2.0)), None);
|
||||
assert_eq!(k.update((2.0, 2.0)), None);
|
||||
let v = k.update((3.0, 3.0)).unwrap();
|
||||
assert!((-1.0..=1.0).contains(&v), "got {v}");
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,7 @@ mod intraday_momentum_index;
|
||||
mod intraday_volatility_profile;
|
||||
mod inverse_fisher_transform;
|
||||
mod inverted_hammer;
|
||||
mod jarque_bera;
|
||||
mod jma;
|
||||
mod jump_indicator;
|
||||
mod kagi_bars;
|
||||
@@ -200,6 +201,7 @@ mod kase_devstop;
|
||||
mod kase_permission_stochastic;
|
||||
mod kelly_criterion;
|
||||
mod keltner;
|
||||
mod kendall_tau;
|
||||
mod kicking;
|
||||
mod kicking_by_length;
|
||||
mod kst;
|
||||
@@ -314,6 +316,7 @@ mod roll_measure;
|
||||
mod rolling_correlation;
|
||||
mod rolling_covariance;
|
||||
mod rolling_iqr;
|
||||
mod rolling_min_max_scaler;
|
||||
mod rolling_percentile_rank;
|
||||
mod rolling_quantile;
|
||||
mod roofing_filter;
|
||||
@@ -322,12 +325,14 @@ mod rsx;
|
||||
mod rvi;
|
||||
mod rvi_volatility;
|
||||
mod rwi;
|
||||
mod sample_entropy;
|
||||
mod sar_ext;
|
||||
mod seasonal_z_score;
|
||||
mod separating_lines;
|
||||
mod session_high_low;
|
||||
mod session_range;
|
||||
mod session_vwap;
|
||||
mod shannon_entropy;
|
||||
mod shark;
|
||||
mod sharpe_ratio;
|
||||
mod shooting_star;
|
||||
@@ -638,6 +643,7 @@ pub use intraday_momentum_index::IntradayMomentumIndex;
|
||||
pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput};
|
||||
pub use inverse_fisher_transform::InverseFisherTransform;
|
||||
pub use inverted_hammer::InvertedHammer;
|
||||
pub use jarque_bera::JarqueBera;
|
||||
pub use jma::Jma;
|
||||
pub use jump_indicator::JumpIndicator;
|
||||
pub use kagi_bars::{KagiBar, KagiBars};
|
||||
@@ -647,6 +653,7 @@ pub use kase_devstop::{KaseDevStop, KaseDevStopOutput};
|
||||
pub use kase_permission_stochastic::{KasePermissionStochastic, KasePermissionStochasticOutput};
|
||||
pub use kelly_criterion::KellyCriterion;
|
||||
pub use keltner::{Keltner, KeltnerOutput};
|
||||
pub use kendall_tau::KendallTau;
|
||||
pub use kicking::Kicking;
|
||||
pub use kicking_by_length::KickingByLength;
|
||||
pub use kst::{Kst, KstOutput};
|
||||
@@ -761,6 +768,7 @@ pub use roll_measure::RollMeasure;
|
||||
pub use rolling_correlation::RollingCorrelation;
|
||||
pub use rolling_covariance::RollingCovariance;
|
||||
pub use rolling_iqr::RollingIqr;
|
||||
pub use rolling_min_max_scaler::RollingMinMaxScaler;
|
||||
pub use rolling_percentile_rank::RollingPercentileRank;
|
||||
pub use rolling_quantile::RollingQuantile;
|
||||
pub use roofing_filter::RoofingFilter;
|
||||
@@ -769,12 +777,14 @@ pub use rsx::Rsx;
|
||||
pub use rvi::Rvi;
|
||||
pub use rvi_volatility::RviVolatility;
|
||||
pub use rwi::{Rwi, RwiOutput};
|
||||
pub use sample_entropy::SampleEntropy;
|
||||
pub use sar_ext::SarExt;
|
||||
pub use seasonal_z_score::SeasonalZScore;
|
||||
pub use separating_lines::SeparatingLines;
|
||||
pub use session_high_low::{SessionHighLow, SessionHighLowOutput};
|
||||
pub use session_range::{SessionRange, SessionRangeOutput};
|
||||
pub use session_vwap::SessionVwap;
|
||||
pub use shannon_entropy::ShannonEntropy;
|
||||
pub use shark::Shark;
|
||||
pub use sharpe_ratio::SharpeRatio;
|
||||
pub use shooting_star::ShootingStar;
|
||||
@@ -1191,6 +1201,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"BodySizePct",
|
||||
"WickRatio",
|
||||
"HighLowRange",
|
||||
"JarqueBera",
|
||||
"RollingMinMaxScaler",
|
||||
"ShannonEntropy",
|
||||
"SampleEntropy",
|
||||
"KendallTau",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1495,6 +1510,6 @@ mod family_tests {
|
||||
// the actual indicator count is the early-warning signal that an
|
||||
// indicator was added without being assigned a family.
|
||||
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
|
||||
assert_eq!(total, 447, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 452, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Rolling Min-Max Scaler — normalises the latest value to `[0, 1]` over a window.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling Min-Max Scaler — maps the current value onto `[0, 1]` relative to the
|
||||
/// minimum and maximum of the trailing window.
|
||||
///
|
||||
/// ```text
|
||||
/// scaled = (x − min(window)) / (max(window) − min(window))
|
||||
/// ```
|
||||
///
|
||||
/// This is the streaming form of scikit-learn's `MinMaxScaler` applied over a
|
||||
/// sliding window: `0` means the value is the lowest in the window, `1` the
|
||||
/// highest, `0.5` the midpoint of the range. It is the engine behind oscillators
|
||||
/// like the Stochastic %K and a handy normaliser for feeding any indicator into a
|
||||
/// bounded model input. Because it rescales to the window's own range it is
|
||||
/// scale-free across instruments.
|
||||
///
|
||||
/// The output is in `[0, 1]`. A flat window (`max == min`) has no range to scale
|
||||
/// against and returns the neutral `0.5`. The first value lands after `period`
|
||||
/// inputs; each `update` scans the window in O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, RollingMinMaxScaler};
|
||||
///
|
||||
/// let mut indicator = RollingMinMaxScaler::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RollingMinMaxScaler {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl RollingMinMaxScaler {
|
||||
/// Construct a rolling min-max scaler over `period` values.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::InvalidPeriod`] if `period < 2` (a range needs two points).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "min-max scaler needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for RollingMinMaxScaler {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let mut min = f64::INFINITY;
|
||||
let mut max = f64::NEG_INFINITY;
|
||||
for &v in &self.window {
|
||||
min = min.min(v);
|
||||
max = max.max(v);
|
||||
}
|
||||
let range = max - min;
|
||||
let scaled = if range > 0.0 {
|
||||
(input - min) / range
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
self.last = Some(scaled);
|
||||
Some(scaled)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"RollingMinMaxScaler"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_period() {
|
||||
assert!(matches!(
|
||||
RollingMinMaxScaler::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
RollingMinMaxScaler::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(RollingMinMaxScaler::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = RollingMinMaxScaler::new(14).unwrap();
|
||||
assert_eq!(s.period(), 14);
|
||||
assert_eq!(s.warmup_period(), 14);
|
||||
assert_eq!(s.name(), "RollingMinMaxScaler");
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut s = RollingMinMaxScaler::new(4).unwrap();
|
||||
let out = s.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highest_in_window_is_one() {
|
||||
let mut s = RollingMinMaxScaler::new(4).unwrap();
|
||||
// last value is the highest -> 1.0.
|
||||
let last = s
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowest_in_window_is_zero() {
|
||||
let mut s = RollingMinMaxScaler::new(4).unwrap();
|
||||
let last = s
|
||||
.batch(&[4.0, 3.0, 2.0, 1.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midpoint_is_half() {
|
||||
let mut s = RollingMinMaxScaler::new(3).unwrap();
|
||||
// window [0, 2, 1]: min 0, max 2, current 1 -> 0.5.
|
||||
let last = s
|
||||
.batch(&[0.0, 2.0, 1.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_is_half() {
|
||||
let mut s = RollingMinMaxScaler::new(4).unwrap();
|
||||
let last = s.batch(&[7.0; 8]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut s = RollingMinMaxScaler::new(14).unwrap();
|
||||
for v in s
|
||||
.batch(
|
||||
&(0..200)
|
||||
.map(|i| (f64::from(i) * 0.3).sin() * 10.0)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((0.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut s = RollingMinMaxScaler::new(4).unwrap();
|
||||
let ready = s
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(s.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut s = RollingMinMaxScaler::new(4).unwrap();
|
||||
s.batch(&[1.0, 2.0, 3.0, 4.0]);
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.value(), None);
|
||||
assert_eq!(s.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = RollingMinMaxScaler::new(14).unwrap().batch(&xs);
|
||||
let mut b = RollingMinMaxScaler::new(14).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Sample Entropy (`SampEn`) — the regularity / predictability of a window.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Population standard deviation of a slice (used for the matching tolerance).
|
||||
fn population_stddev(window: &[f64]) -> f64 {
|
||||
let n = window.len() as f64;
|
||||
let mean = window.iter().sum::<f64>() / n;
|
||||
let var = window.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / n;
|
||||
var.max(0.0).sqrt()
|
||||
}
|
||||
|
||||
/// Whether two length-`len` templates starting at `i` and `j` match within the
|
||||
/// Chebyshev tolerance `tol`.
|
||||
fn templates_match(window: &[f64], i: usize, j: usize, len: usize, tol: f64) -> bool {
|
||||
for k in 0..len {
|
||||
if (window[i + k] - window[j + k]).abs() > tol {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Sample Entropy (`SampEn`) — Richman & Moorman's measure of how *regular* (i.e.
|
||||
/// predictable) a series is: the negative log conditional probability that two
|
||||
/// sub-sequences similar for `m` points stay similar at the next point.
|
||||
///
|
||||
/// ```text
|
||||
/// tol = r_factor · stddev(window)
|
||||
/// B = # template pairs of length m within tol (i < j)
|
||||
/// A = # template pairs of length m+1 within tol (i < j)
|
||||
/// `SampEn` = − ln(A / B)
|
||||
/// ```
|
||||
///
|
||||
/// Low `SampEn` means the window is **regular** — patterns of length `m` reliably
|
||||
/// extend to length `m + 1`, the fingerprint of a trending or cyclic market. High
|
||||
/// `SampEn` means the series is **irregular** — knowing the last `m` points tells
|
||||
/// you little about the next, the fingerprint of noise. Unlike the older
|
||||
/// approximate entropy (`ApEn`), `SampEn` excludes self-matches, so it is far less
|
||||
/// biased on short windows.
|
||||
///
|
||||
/// The tolerance is `r_factor` times the window's standard deviation, so the
|
||||
/// measure self-scales. A perfectly flat window (`stddev == 0`) is maximally
|
||||
/// regular and returns `0`. If no length-`m` pairs match, the entropy is
|
||||
/// undefined and `0` is returned; if length-`m` pairs match but none extend, the
|
||||
/// estimator falls back to treating the unseen count as one (`−ln(1/B) = ln(B)`).
|
||||
/// The first value lands after `period` inputs; each `update` is O(`period²`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, SampleEntropy};
|
||||
///
|
||||
/// let mut indicator = SampleEntropy::new(50, 2, 0.2).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update((f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SampleEntropy {
|
||||
period: usize,
|
||||
emb_dim: usize,
|
||||
r_factor: f64,
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl SampleEntropy {
|
||||
/// Construct a Sample Entropy over `period` values with embedding dimension
|
||||
/// `m` and tolerance factor `r_factor`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period` or `m` is `0`,
|
||||
/// [`Error::InvalidPeriod`] if `period < m + 2` (no length-`m+1` template
|
||||
/// pairs otherwise), and [`Error::InvalidParameter`] if `r_factor` is not
|
||||
/// finite and positive.
|
||||
pub fn new(period: usize, m: usize, r_factor: f64) -> Result<Self> {
|
||||
if period == 0 || m == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period < m + 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "sample entropy needs period >= m + 2",
|
||||
});
|
||||
}
|
||||
if !r_factor.is_finite() || r_factor <= 0.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "sample entropy r_factor must be finite and positive",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
emb_dim: m,
|
||||
r_factor,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, m, r_factor)`.
|
||||
pub const fn params(&self) -> (usize, usize, f64) {
|
||||
(self.period, self.emb_dim, self.r_factor)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
let window: Vec<f64> = self.window.iter().copied().collect();
|
||||
let std = population_stddev(&window);
|
||||
if std == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let tol = self.r_factor * std;
|
||||
let m = self.emb_dim;
|
||||
// Restrict both template lengths to the same index range so A and B share
|
||||
// their candidate pairs: there are `period − m` length-(m+1) templates.
|
||||
let count = self.period - m;
|
||||
let mut matches_m = 0u64;
|
||||
let mut matches_m1 = 0u64;
|
||||
for i in 0..count {
|
||||
for j in (i + 1)..count {
|
||||
if templates_match(&window, i, j, m, tol) {
|
||||
matches_m += 1;
|
||||
if templates_match(&window, i, j, m + 1, tol) {
|
||||
matches_m1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches_m == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
if matches_m1 == 0 {
|
||||
// No length-(m+1) matches: fall back to one unseen count.
|
||||
return (matches_m as f64).ln();
|
||||
}
|
||||
-((matches_m1 as f64) / (matches_m as f64)).ln()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for SampleEntropy {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let out = self.compute();
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"SampleEntropy"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(
|
||||
SampleEntropy::new(0, 2, 0.2),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
SampleEntropy::new(50, 0, 0.2),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
SampleEntropy::new(3, 2, 0.2),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
SampleEntropy::new(50, 2, 0.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = SampleEntropy::new(50, 2, 0.2).unwrap();
|
||||
assert_eq!(s.params(), (50, 2, 0.2));
|
||||
assert_eq!(s.warmup_period(), 50);
|
||||
assert_eq!(s.name(), "SampleEntropy");
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut s = SampleEntropy::new(10, 2, 0.2).unwrap();
|
||||
let xs: Vec<f64> = (0..14).map(|i| (f64::from(i) * 0.5).sin()).collect();
|
||||
let out = s.batch(&xs);
|
||||
for v in out.iter().take(9) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[9].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_window_is_zero() {
|
||||
let mut s = SampleEntropy::new(20, 2, 0.2).unwrap();
|
||||
let last = s.batch(&[5.0; 30]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_non_negative() {
|
||||
let mut s = SampleEntropy::new(40, 2, 0.2).unwrap();
|
||||
for v in s
|
||||
.batch(
|
||||
&(0..200)
|
||||
.map(|i| (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!(v >= 0.0, "sample entropy must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_below_irregular() {
|
||||
// A smooth sine is far more regular (lower `SampEn`) than a chaotic
|
||||
// logistic-map series. (An *alternating* series would be periodic, hence
|
||||
// regular too -- chaos is what makes the window genuinely unpredictable.)
|
||||
let smooth: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.2).sin() * 5.0).collect();
|
||||
let mut x = 0.37_f64;
|
||||
let chaotic: Vec<f64> = (0..60)
|
||||
.map(|_| {
|
||||
x = 3.99 * x * (1.0 - x);
|
||||
x * 5.0
|
||||
})
|
||||
.collect();
|
||||
let s_smooth = SampleEntropy::new(50, 2, 0.2)
|
||||
.unwrap()
|
||||
.batch(&smooth)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
let s_chaotic = SampleEntropy::new(50, 2, 0.2)
|
||||
.unwrap()
|
||||
.batch(&chaotic)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(
|
||||
s_smooth <= s_chaotic,
|
||||
"smooth ({s_smooth}) should be <= chaotic ({s_chaotic})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut s = SampleEntropy::new(10, 2, 0.2).unwrap();
|
||||
let xs: Vec<f64> = (0..10).map(|i| (f64::from(i) * 0.5).sin()).collect();
|
||||
let ready = s.batch(&xs).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(s.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut s = SampleEntropy::new(10, 2, 0.2).unwrap();
|
||||
let xs: Vec<f64> = (0..10).map(|i| (f64::from(i) * 0.5).sin()).collect();
|
||||
s.batch(&xs);
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.value(), None);
|
||||
assert_eq!(s.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = SampleEntropy::new(40, 2, 0.2).unwrap().batch(&xs);
|
||||
let mut b = SampleEntropy::new(40, 2, 0.2).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_when_no_m_plus_one_matches() {
|
||||
// `[1, 1, 1, 5]` with m = 2: the length-2 template `(1, 1)` repeats
|
||||
// (matches_m > 0) but no length-3 template repeats (matches_m1 == 0),
|
||||
// so SampEn takes the `ln(matches_m)` fallback branch.
|
||||
let xs = [1.0, 1.0, 1.0, 5.0];
|
||||
let v = SampleEntropy::new(4, 2, 0.2)
|
||||
.unwrap()
|
||||
.batch(&xs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(v.is_finite() && v >= 0.0, "got {v}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Shannon Entropy — the information content of a price window's distribution.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Shannon Entropy — the Shannon information entropy (in **bits**) of the
|
||||
/// distribution of values in a rolling window, after binning them into a fixed
|
||||
/// number of equal-width buckets.
|
||||
///
|
||||
/// ```text
|
||||
/// bucket each of the last `period` values into `bins` equal-width bins over
|
||||
/// [min, max] of the window
|
||||
/// p_i = count_i / period
|
||||
/// H = − Σ p_i · log2(p_i) (over non-empty bins)
|
||||
/// ```
|
||||
///
|
||||
/// Entropy measures how *spread out* and unpredictable the recent values are. A
|
||||
/// window concentrated in one bin (a flat or tightly-ranging market) has low
|
||||
/// entropy near `0`; a window whose values are spread evenly across all bins (a
|
||||
/// noisy, directionless market) approaches the maximum `log2(bins)`. Traders use
|
||||
/// it as a **regime filter**: low entropy favours trend/breakout strategies, high
|
||||
/// entropy favours mean-reversion or standing aside.
|
||||
///
|
||||
/// The output lies in `[0, log2(bins)]`. A degenerate window where every value is
|
||||
/// identical (`max == min`) returns `0`. The first value lands after `period`
|
||||
/// inputs; each `update` rebins the window in O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, ShannonEntropy};
|
||||
///
|
||||
/// let mut indicator = ShannonEntropy::new(32, 8).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..64 {
|
||||
/// last = indicator.update((f64::from(i) * 0.7).sin() * 10.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShannonEntropy {
|
||||
period: usize,
|
||||
bins: usize,
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl ShannonEntropy {
|
||||
/// Construct a Shannon entropy over `period` values binned into `bins`
|
||||
/// buckets.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either argument is `0`, or
|
||||
/// [`Error::InvalidPeriod`] if `bins < 2` (entropy needs at least two bins).
|
||||
pub fn new(period: usize, bins: usize) -> Result<Self> {
|
||||
if period == 0 || bins == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if bins < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "Shannon entropy needs bins >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
bins,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, bins)`.
|
||||
pub const fn params(&self) -> (usize, usize) {
|
||||
(self.period, self.bins)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ShannonEntropy {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut min = f64::INFINITY;
|
||||
let mut max = f64::NEG_INFINITY;
|
||||
for &v in &self.window {
|
||||
min = min.min(v);
|
||||
max = max.max(v);
|
||||
}
|
||||
if max <= min {
|
||||
// Degenerate window: all values identical -> zero entropy.
|
||||
self.last = Some(0.0);
|
||||
return Some(0.0);
|
||||
}
|
||||
let width = (max - min) / self.bins as f64;
|
||||
let mut counts = vec![0usize; self.bins];
|
||||
for &v in &self.window {
|
||||
// `(v - min) / width` is in [0, bins]; the cast truncates toward zero
|
||||
// (intended) and the value is non-negative, then clamped to the last
|
||||
// bin so the index is always valid.
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
let raw = ((v - min) / width) as usize;
|
||||
let idx = raw.min(self.bins - 1);
|
||||
counts[idx] += 1;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let mut h = 0.0;
|
||||
for &count in &counts {
|
||||
if count > 0 {
|
||||
let p = count as f64 / n;
|
||||
h -= p * p.log2();
|
||||
}
|
||||
}
|
||||
self.last = Some(h);
|
||||
Some(h)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ShannonEntropy"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(ShannonEntropy::new(0, 8), Err(Error::PeriodZero)));
|
||||
assert!(matches!(ShannonEntropy::new(32, 0), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
ShannonEntropy::new(32, 1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let e = ShannonEntropy::new(32, 8).unwrap();
|
||||
assert_eq!(e.params(), (32, 8));
|
||||
assert_eq!(e.warmup_period(), 32);
|
||||
assert_eq!(e.name(), "ShannonEntropy");
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut e = ShannonEntropy::new(4, 4).unwrap();
|
||||
let out = e.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_window_is_zero() {
|
||||
let mut e = ShannonEntropy::new(8, 4).unwrap();
|
||||
let last = e.batch(&[5.0; 12]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniform_window_is_max_entropy() {
|
||||
// One value per bin -> uniform distribution -> H = log2(bins).
|
||||
let mut e = ShannonEntropy::new(4, 4).unwrap();
|
||||
// Values 0,1,2,3 with min=0,max=3,width=0.75 -> bins 0,1,2,3.
|
||||
let last = e
|
||||
.batch(&[0.0, 1.0, 2.0, 3.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 2.0, epsilon = 1e-9); // log2(4) = 2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut e = ShannonEntropy::new(32, 8).unwrap();
|
||||
let max_h = 8f64.log2();
|
||||
for v in e
|
||||
.batch(
|
||||
&(0..200)
|
||||
.map(|i| (f64::from(i) * 0.3).sin() * 10.0)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((0.0..=max_h + 1e-9).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut e = ShannonEntropy::new(4, 4).unwrap();
|
||||
let ready = e
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(e.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut e = ShannonEntropy::new(4, 4).unwrap();
|
||||
e.batch(&[1.0, 2.0, 3.0, 4.0]);
|
||||
assert!(e.is_ready());
|
||||
e.reset();
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
assert_eq!(e.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = ShannonEntropy::new(32, 8).unwrap().batch(&xs);
|
||||
let mut b = ShannonEntropy::new(32, 8).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user