Skip to main content

wickra_core/indicators/
shannon_entropy.rs

1//! Shannon Entropy — the information content of a price window's distribution.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Shannon Entropy — the Shannon information entropy (in **bits**) of the
9/// distribution of values in a rolling window, after binning them into a fixed
10/// number of equal-width buckets.
11///
12/// ```text
13/// bucket each of the last `period` values into `bins` equal-width bins over
14///   [min, max] of the window
15/// p_i = count_i / period
16/// H   = − Σ p_i · log2(p_i)            (over non-empty bins)
17/// ```
18///
19/// Entropy measures how *spread out* and unpredictable the recent values are. A
20/// window concentrated in one bin (a flat or tightly-ranging market) has low
21/// entropy near `0`; a window whose values are spread evenly across all bins (a
22/// noisy, directionless market) approaches the maximum `log2(bins)`. Traders use
23/// it as a **regime filter**: low entropy favours trend/breakout strategies, high
24/// entropy favours mean-reversion or standing aside.
25///
26/// The output lies in `[0, log2(bins)]`. A degenerate window where every value is
27/// identical (`max == min`) returns `0`. The first value lands after `period`
28/// inputs; each `update` rebins the window in O(`period`).
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{Indicator, ShannonEntropy};
34///
35/// let mut indicator = ShannonEntropy::new(32, 8).unwrap();
36/// let mut last = None;
37/// for i in 0..64 {
38///     last = indicator.update((f64::from(i) * 0.7).sin() * 10.0);
39/// }
40/// assert!(last.is_some());
41/// ```
42#[derive(Debug, Clone)]
43pub struct ShannonEntropy {
44    period: usize,
45    bins: usize,
46    window: VecDeque<f64>,
47    last: Option<f64>,
48}
49
50impl ShannonEntropy {
51    /// Construct a Shannon entropy over `period` values binned into `bins`
52    /// buckets.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`Error::PeriodZero`] if either argument is `0`, or
57    /// [`Error::InvalidPeriod`] if `bins < 2` (entropy needs at least two bins).
58    pub fn new(period: usize, bins: usize) -> Result<Self> {
59        if period == 0 || bins == 0 {
60            return Err(Error::PeriodZero);
61        }
62        if bins < 2 {
63            return Err(Error::InvalidPeriod {
64                message: "Shannon entropy needs bins >= 2",
65            });
66        }
67        if bins > crate::error::MAX_PERIOD {
68            return Err(Error::InvalidPeriod {
69                message: crate::error::PERIOD_ABOVE_MAX,
70            });
71        }
72        Ok(Self {
73            period,
74            bins,
75            window: VecDeque::with_capacity(period),
76            last: None,
77        })
78    }
79
80    /// Configured `(period, bins)`.
81    pub const fn params(&self) -> (usize, usize) {
82        (self.period, self.bins)
83    }
84
85    /// Current value if available.
86    pub const fn value(&self) -> Option<f64> {
87        self.last
88    }
89}
90
91impl Indicator for ShannonEntropy {
92    type Input = f64;
93    type Output = f64;
94
95    fn update(&mut self, input: f64) -> Option<f64> {
96        if !input.is_finite() {
97            return None;
98        }
99        if self.window.len() == self.period {
100            self.window.pop_front();
101        }
102        self.window.push_back(input);
103        if self.window.len() < self.period {
104            return None;
105        }
106
107        let mut min = f64::INFINITY;
108        let mut max = f64::NEG_INFINITY;
109        for &v in &self.window {
110            min = min.min(v);
111            max = max.max(v);
112        }
113        if max <= min {
114            // Degenerate window: all values identical -> zero entropy.
115            self.last = Some(0.0);
116            return Some(0.0);
117        }
118        let width = (max - min) / self.bins as f64;
119        let mut counts = vec![0usize; self.bins];
120        for &v in &self.window {
121            // `(v - min) / width` is in [0, bins]; the cast truncates toward zero
122            // (intended) and the value is non-negative, then clamped to the last
123            // bin so the index is always valid.
124            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
125            let raw = ((v - min) / width) as usize;
126            let idx = raw.min(self.bins - 1);
127            counts[idx] += 1;
128        }
129        let n = self.period as f64;
130        let mut h = 0.0;
131        for &count in &counts {
132            if count > 0 {
133                let p = count as f64 / n;
134                h -= p * p.log2();
135            }
136        }
137        self.last = Some(h);
138        Some(h)
139    }
140
141    fn reset(&mut self) {
142        self.window.clear();
143        self.last = None;
144    }
145
146    #[inline]
147    fn warmup_period(&self) -> usize {
148        self.period
149    }
150
151    #[inline]
152    fn is_ready(&self) -> bool {
153        self.last.is_some()
154    }
155
156    #[inline]
157    fn name(&self) -> &'static str {
158        "ShannonEntropy"
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::traits::BatchExt;
166    use approx::assert_relative_eq;
167
168    #[test]
169    fn rejects_invalid_params() {
170        assert!(matches!(ShannonEntropy::new(0, 8), Err(Error::PeriodZero)));
171        assert!(matches!(ShannonEntropy::new(32, 0), Err(Error::PeriodZero)));
172        assert!(matches!(
173            ShannonEntropy::new(32, 1),
174            Err(Error::InvalidPeriod { .. })
175        ));
176    }
177
178    #[test]
179    fn accessors_and_metadata() {
180        let e = ShannonEntropy::new(32, 8).unwrap();
181        assert_eq!(e.params(), (32, 8));
182        assert_eq!(e.warmup_period(), 32);
183        assert_eq!(e.name(), "ShannonEntropy");
184        assert!(!e.is_ready());
185        assert_eq!(e.value(), None);
186    }
187
188    #[test]
189    fn first_emission_at_warmup_period() {
190        let mut e = ShannonEntropy::new(4, 4).unwrap();
191        let out = e.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
192        for v in out.iter().take(3) {
193            assert!(v.is_none());
194        }
195        assert!(out[3].is_some());
196    }
197
198    #[test]
199    fn constant_window_is_zero() {
200        let mut e = ShannonEntropy::new(8, 4).unwrap();
201        let last = e.batch(&[5.0; 12]).into_iter().flatten().last().unwrap();
202        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
203    }
204
205    #[test]
206    fn uniform_window_is_max_entropy() {
207        // One value per bin -> uniform distribution -> H = log2(bins).
208        let mut e = ShannonEntropy::new(4, 4).unwrap();
209        // Values 0,1,2,3 with min=0,max=3,width=0.75 -> bins 0,1,2,3.
210        let last = e
211            .batch(&[0.0, 1.0, 2.0, 3.0])
212            .into_iter()
213            .flatten()
214            .last()
215            .unwrap();
216        assert_relative_eq!(last, 2.0, epsilon = 1e-9); // log2(4) = 2
217    }
218
219    #[test]
220    fn output_in_range() {
221        let mut e = ShannonEntropy::new(32, 8).unwrap();
222        let max_h = 8f64.log2();
223        for v in e
224            .batch(
225                &(0..200)
226                    .map(|i| (f64::from(i) * 0.3).sin() * 10.0)
227                    .collect::<Vec<_>>(),
228            )
229            .into_iter()
230            .flatten()
231        {
232            assert!((0.0..=max_h + 1e-9).contains(&v));
233        }
234    }
235
236    #[test]
237    fn ignores_non_finite() {
238        let mut e = ShannonEntropy::new(4, 4).unwrap();
239        let _ready = e
240            .batch(&[1.0, 2.0, 3.0, 4.0])
241            .into_iter()
242            .flatten()
243            .last()
244            .unwrap();
245        assert_eq!(e.update(f64::NAN), None);
246    }
247
248    #[test]
249    fn reset_clears_state() {
250        let mut e = ShannonEntropy::new(4, 4).unwrap();
251        e.batch(&[1.0, 2.0, 3.0, 4.0]);
252        assert!(e.is_ready());
253        e.reset();
254        assert!(!e.is_ready());
255        assert_eq!(e.value(), None);
256        assert_eq!(e.update(1.0), None);
257    }
258
259    #[test]
260    fn batch_equals_streaming() {
261        let xs: Vec<f64> = (0..120)
262            .map(|i| (f64::from(i) * 0.25).sin() * 9.0)
263            .collect();
264        let batch = ShannonEntropy::new(32, 8).unwrap().batch(&xs);
265        let mut b = ShannonEntropy::new(32, 8).unwrap();
266        let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
267        assert_eq!(batch, streamed);
268    }
269}