Skip to main content

wickra_core/indicators/
sample_entropy.rs

1//! Sample Entropy (`SampEn`) — the regularity / predictability of a window.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Population standard deviation of a slice (used for the matching tolerance).
9fn population_stddev(window: &[f64]) -> f64 {
10    let n = window.len() as f64;
11    let mean = window.iter().sum::<f64>() / n;
12    let var = window.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / n;
13    var.max(0.0).sqrt()
14}
15
16/// Whether two length-`len` templates starting at `i` and `j` match within the
17/// Chebyshev tolerance `tol`.
18fn templates_match(window: &[f64], i: usize, j: usize, len: usize, tol: f64) -> bool {
19    for k in 0..len {
20        if (window[i + k] - window[j + k]).abs() > tol {
21            return false;
22        }
23    }
24    true
25}
26
27/// Sample Entropy (`SampEn`) — Richman & Moorman's measure of how *regular* (i.e.
28/// predictable) a series is: the negative log conditional probability that two
29/// sub-sequences similar for `m` points stay similar at the next point.
30///
31/// ```text
32/// tol = r_factor · stddev(window)
33/// B   = # template pairs of length m   within tol   (i < j)
34/// A   = # template pairs of length m+1 within tol   (i < j)
35/// `SampEn` = − ln(A / B)
36/// ```
37///
38/// Low `SampEn` means the window is **regular** — patterns of length `m` reliably
39/// extend to length `m + 1`, the fingerprint of a trending or cyclic market. High
40/// `SampEn` means the series is **irregular** — knowing the last `m` points tells
41/// you little about the next, the fingerprint of noise. Unlike the older
42/// approximate entropy (`ApEn`), `SampEn` excludes self-matches, so it is far less
43/// biased on short windows.
44///
45/// The tolerance is `r_factor` times the window's standard deviation, so the
46/// measure self-scales. A perfectly flat window (`stddev == 0`) is maximally
47/// regular and returns `0`. If no length-`m` pairs match, the entropy is
48/// undefined and `0` is returned; if length-`m` pairs match but none extend, the
49/// estimator falls back to treating the unseen count as one (`−ln(1/B) = ln(B)`).
50/// The first value lands after `period` inputs; each `update` is O(`period²`).
51///
52/// # Example
53///
54/// ```
55/// use wickra_core::{Indicator, SampleEntropy};
56///
57/// let mut indicator = SampleEntropy::new(50, 2, 0.2).unwrap();
58/// let mut last = None;
59/// for i in 0..80 {
60///     last = indicator.update((f64::from(i) * 0.3).sin() * 5.0);
61/// }
62/// assert!(last.is_some());
63/// ```
64#[derive(Debug, Clone)]
65pub struct SampleEntropy {
66    period: usize,
67    emb_dim: usize,
68    r_factor: f64,
69    window: VecDeque<f64>,
70    /// Reusable scratch buffer to avoid allocating per `update`.
71    scratch: Vec<f64>,
72    last: Option<f64>,
73}
74
75impl SampleEntropy {
76    /// Construct a Sample Entropy over `period` values with embedding dimension
77    /// `m` and tolerance factor `r_factor`.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`Error::PeriodZero`] if `period` or `m` is `0`,
82    /// [`Error::InvalidPeriod`] if `period < m + 2` (no length-`m+1` template
83    /// pairs otherwise), and [`Error::InvalidParameter`] if `r_factor` is not
84    /// finite and positive.
85    pub fn new(period: usize, m: usize, r_factor: f64) -> Result<Self> {
86        if period == 0 || m == 0 {
87            return Err(Error::PeriodZero);
88        }
89        if period < m + 2 {
90            return Err(Error::InvalidPeriod {
91                message: "sample entropy needs period >= m + 2",
92            });
93        }
94        if !r_factor.is_finite() || r_factor <= 0.0 {
95            return Err(Error::InvalidParameter {
96                message: "sample entropy r_factor must be finite and positive",
97            });
98        }
99        Ok(Self {
100            period,
101            emb_dim: m,
102            r_factor,
103            window: VecDeque::with_capacity(period),
104            scratch: Vec::with_capacity(period),
105            last: None,
106        })
107    }
108
109    /// Configured `(period, m, r_factor)`.
110    pub const fn params(&self) -> (usize, usize, f64) {
111        (self.period, self.emb_dim, self.r_factor)
112    }
113
114    /// Current value if available.
115    pub const fn value(&self) -> Option<f64> {
116        self.last
117    }
118
119    fn compute(&mut self) -> f64 {
120        self.scratch.clear();
121        self.scratch.extend(self.window.iter().copied());
122        let window = &self.scratch;
123        let std = population_stddev(window);
124        if std == 0.0 {
125            return 0.0;
126        }
127        let tol = self.r_factor * std;
128        let m = self.emb_dim;
129        // Restrict both template lengths to the same index range so A and B share
130        // their candidate pairs: there are `period − m` length-(m+1) templates.
131        let count = self.period - m;
132        let mut matches_m = 0u64;
133        let mut matches_m1 = 0u64;
134        for i in 0..count {
135            for j in (i + 1)..count {
136                if templates_match(window, i, j, m, tol) {
137                    matches_m += 1;
138                    if templates_match(window, i, j, m + 1, tol) {
139                        matches_m1 += 1;
140                    }
141                }
142            }
143        }
144        if matches_m == 0 {
145            return 0.0;
146        }
147        if matches_m1 == 0 {
148            // No length-(m+1) matches: fall back to one unseen count.
149            return (matches_m as f64).ln();
150        }
151        -((matches_m1 as f64) / (matches_m as f64)).ln()
152    }
153}
154
155impl Indicator for SampleEntropy {
156    type Input = f64;
157    type Output = f64;
158
159    #[inline]
160    fn update(&mut self, input: f64) -> Option<f64> {
161        if !input.is_finite() {
162            return None;
163        }
164        if self.window.len() == self.period {
165            self.window.pop_front();
166        }
167        self.window.push_back(input);
168        if self.window.len() < self.period {
169            return None;
170        }
171        let out = self.compute();
172        self.last = Some(out);
173        Some(out)
174    }
175
176    fn reset(&mut self) {
177        self.window.clear();
178        self.scratch.clear();
179        self.last = None;
180    }
181
182    #[inline]
183    fn warmup_period(&self) -> usize {
184        self.period
185    }
186
187    #[inline]
188    fn is_ready(&self) -> bool {
189        self.last.is_some()
190    }
191
192    #[inline]
193    fn name(&self) -> &'static str {
194        "SampleEntropy"
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::traits::BatchExt;
202    use approx::assert_relative_eq;
203
204    #[test]
205    fn rejects_invalid_params() {
206        assert!(matches!(
207            SampleEntropy::new(0, 2, 0.2),
208            Err(Error::PeriodZero)
209        ));
210        assert!(matches!(
211            SampleEntropy::new(50, 0, 0.2),
212            Err(Error::PeriodZero)
213        ));
214        assert!(matches!(
215            SampleEntropy::new(3, 2, 0.2),
216            Err(Error::InvalidPeriod { .. })
217        ));
218        assert!(matches!(
219            SampleEntropy::new(50, 2, 0.0),
220            Err(Error::InvalidParameter { .. })
221        ));
222    }
223
224    #[test]
225    fn accessors_and_metadata() {
226        let s = SampleEntropy::new(50, 2, 0.2).unwrap();
227        assert_eq!(s.params(), (50, 2, 0.2));
228        assert_eq!(s.warmup_period(), 50);
229        assert_eq!(s.name(), "SampleEntropy");
230        assert!(!s.is_ready());
231        assert_eq!(s.value(), None);
232    }
233
234    #[test]
235    fn first_emission_at_warmup_period() {
236        let mut s = SampleEntropy::new(10, 2, 0.2).unwrap();
237        let xs: Vec<f64> = (0..14).map(|i| (f64::from(i) * 0.5).sin()).collect();
238        let out = s.batch(&xs);
239        for v in out.iter().take(9) {
240            assert!(v.is_none());
241        }
242        assert!(out[9].is_some());
243    }
244
245    #[test]
246    fn constant_window_is_zero() {
247        let mut s = SampleEntropy::new(20, 2, 0.2).unwrap();
248        let last = s.batch(&[5.0; 30]).into_iter().flatten().last().unwrap();
249        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
250    }
251
252    #[test]
253    fn output_is_non_negative() {
254        let mut s = SampleEntropy::new(40, 2, 0.2).unwrap();
255        for v in s
256            .batch(
257                &(0..200)
258                    .map(|i| (f64::from(i) * 0.3).sin() * 5.0)
259                    .collect::<Vec<_>>(),
260            )
261            .into_iter()
262            .flatten()
263        {
264            assert!(v >= 0.0, "sample entropy must be non-negative, got {v}");
265        }
266    }
267
268    #[test]
269    fn regular_below_irregular() {
270        // A smooth sine is far more regular (lower `SampEn`) than a chaotic
271        // logistic-map series. (An *alternating* series would be periodic, hence
272        // regular too -- chaos is what makes the window genuinely unpredictable.)
273        let smooth: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.2).sin() * 5.0).collect();
274        let mut x = 0.37_f64;
275        let chaotic: Vec<f64> = (0..60)
276            .map(|_| {
277                x = 3.99 * x * (1.0 - x);
278                x * 5.0
279            })
280            .collect();
281        let s_smooth = SampleEntropy::new(50, 2, 0.2)
282            .unwrap()
283            .batch(&smooth)
284            .into_iter()
285            .flatten()
286            .last()
287            .unwrap();
288        let s_chaotic = SampleEntropy::new(50, 2, 0.2)
289            .unwrap()
290            .batch(&chaotic)
291            .into_iter()
292            .flatten()
293            .last()
294            .unwrap();
295        assert!(
296            s_smooth <= s_chaotic,
297            "smooth ({s_smooth}) should be <= chaotic ({s_chaotic})"
298        );
299    }
300
301    #[test]
302    fn ignores_non_finite() {
303        let mut s = SampleEntropy::new(10, 2, 0.2).unwrap();
304        let xs: Vec<f64> = (0..10).map(|i| (f64::from(i) * 0.5).sin()).collect();
305        s.batch(&xs).into_iter().flatten().last().unwrap();
306        assert_eq!(s.update(f64::NAN), None);
307    }
308
309    #[test]
310    fn reset_clears_state() {
311        let mut s = SampleEntropy::new(10, 2, 0.2).unwrap();
312        let xs: Vec<f64> = (0..10).map(|i| (f64::from(i) * 0.5).sin()).collect();
313        s.batch(&xs);
314        assert!(s.is_ready());
315        s.reset();
316        assert!(!s.is_ready());
317        assert_eq!(s.value(), None);
318        assert_eq!(s.update(1.0), None);
319    }
320
321    #[test]
322    fn batch_equals_streaming() {
323        let xs: Vec<f64> = (0..120)
324            .map(|i| (f64::from(i) * 0.25).sin() * 9.0)
325            .collect();
326        let batch = SampleEntropy::new(40, 2, 0.2).unwrap().batch(&xs);
327        let mut b = SampleEntropy::new(40, 2, 0.2).unwrap();
328        let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
329        assert_eq!(batch, streamed);
330    }
331
332    #[test]
333    fn falls_back_when_no_m_plus_one_matches() {
334        // `[1, 1, 1, 5]` with m = 2: the length-2 template `(1, 1)` repeats
335        // (matches_m > 0) but no length-3 template repeats (matches_m1 == 0),
336        // so SampEn takes the `ln(matches_m)` fallback branch.
337        let xs = [1.0, 1.0, 1.0, 5.0];
338        let v = SampleEntropy::new(4, 2, 0.2)
339            .unwrap()
340            .batch(&xs)
341            .into_iter()
342            .flatten()
343            .last()
344            .unwrap();
345        assert!(v.is_finite() && v >= 0.0, "got {v}");
346    }
347}