Skip to main content

wickra_core/indicators/
fisher_transform.rs

1//! Ehlers Fisher Transform.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Ehlers' Fisher Transform of price.
9///
10/// Normalises the most recent price to `[-1, +1]` via min/max over a `period`
11/// window, smooths the normalised value with a 0.33 / 0.67 IIR step, and
12/// applies the Fisher transform `0.5 * ln((1+x)/(1-x))`. The result has a
13/// near-Gaussian distribution, so extreme readings stand out cleanly. A
14/// secondary signal is produced by lagging the Fisher value by one bar (the
15/// classic trigger), making the indicator a two-line crossover system in
16/// charts.
17///
18/// Only the primary Fisher value is exposed here as a scalar; the lagged
19/// trigger is one update behind by construction.
20///
21/// # Example
22///
23/// ```
24/// use wickra_core::{Indicator, FisherTransform};
25///
26/// let mut ft = FisherTransform::new(10).unwrap();
27/// let mut last = None;
28/// for i in 0..30 {
29///     last = ft.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
30/// }
31/// assert!(last.is_some());
32/// ```
33#[derive(Debug, Clone)]
34pub struct FisherTransform {
35    period: usize,
36    window: VecDeque<f64>,
37    smoothed: f64,
38    last_fisher: Option<f64>,
39}
40
41impl FisherTransform {
42    /// Construct with the rolling extrema window length.
43    ///
44    /// # Errors
45    ///
46    /// Returns [`Error::PeriodZero`] if `period == 0`.
47    pub fn new(period: usize) -> Result<Self> {
48        if period == 0 {
49            return Err(Error::PeriodZero);
50        }
51        if period > crate::error::MAX_PERIOD {
52            return Err(Error::InvalidPeriod {
53                message: crate::error::PERIOD_ABOVE_MAX,
54            });
55        }
56        Ok(Self {
57            period,
58            window: VecDeque::with_capacity(period),
59            smoothed: 0.0,
60            last_fisher: None,
61        })
62    }
63
64    /// Configured period.
65    pub const fn period(&self) -> usize {
66        self.period
67    }
68
69    /// Current Fisher value if available.
70    pub const fn value(&self) -> Option<f64> {
71        self.last_fisher
72    }
73}
74
75impl Indicator for FisherTransform {
76    type Input = f64;
77    type Output = f64;
78
79    #[inline]
80    fn update(&mut self, input: f64) -> Option<f64> {
81        if !input.is_finite() {
82            return None;
83        }
84        if self.window.len() == self.period {
85            self.window.pop_front();
86        }
87        self.window.push_back(input);
88        if self.window.len() < self.period {
89            return None;
90        }
91        let max = self
92            .window
93            .iter()
94            .copied()
95            .fold(f64::NEG_INFINITY, f64::max);
96        let min = self.window.iter().copied().fold(f64::INFINITY, f64::min);
97        let range = max - min;
98        // Normalise to roughly [-1, +1]; centred midpoint when range == 0.
99        let raw = if range > 0.0 {
100            ((input - min) / range).mul_add(2.0, -1.0)
101        } else {
102            0.0
103        };
104        // Ehlers IIR: 0.33 * raw + 0.67 * prev_smoothed, then clamp.
105        self.smoothed = 0.33f64.mul_add(raw, 0.67 * self.smoothed);
106        // Clamp strictly inside (-1, +1) to keep the log finite.
107        let clamped = self.smoothed.clamp(-0.999, 0.999);
108        let fisher = 0.5 * ((1.0 + clamped) / (1.0 - clamped)).ln();
109        self.last_fisher = Some(fisher);
110        Some(fisher)
111    }
112
113    fn reset(&mut self) {
114        self.window.clear();
115        self.smoothed = 0.0;
116        self.last_fisher = None;
117    }
118
119    #[inline]
120    fn warmup_period(&self) -> usize {
121        self.period
122    }
123
124    #[inline]
125    fn is_ready(&self) -> bool {
126        self.last_fisher.is_some()
127    }
128
129    #[inline]
130    fn name(&self) -> &'static str {
131        "FisherTransform"
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::traits::BatchExt;
139
140    #[test]
141    fn new_rejects_zero_period() {
142        assert!(matches!(FisherTransform::new(0), Err(Error::PeriodZero)));
143    }
144
145    #[test]
146    fn accessors_and_metadata() {
147        let mut ft = FisherTransform::new(10).unwrap();
148        assert_eq!(ft.period(), 10);
149        assert_eq!(ft.warmup_period(), 10);
150        assert_eq!(ft.name(), "FisherTransform");
151        assert!(ft.value().is_none());
152        for i in 1..=10 {
153            ft.update(f64::from(i));
154        }
155        assert!(ft.value().is_some());
156        assert!(ft.is_ready());
157    }
158
159    #[test]
160    fn warmup_returns_none_until_seed() {
161        let mut ft = FisherTransform::new(5).unwrap();
162        for i in 1..=4 {
163            assert_eq!(ft.update(f64::from(i)), None);
164        }
165        assert!(ft.update(5.0).is_some());
166    }
167
168    #[test]
169    fn constant_series_zero_range_yields_zero() {
170        let mut ft = FisherTransform::new(5).unwrap();
171        let out = ft.batch(&[42.0_f64; 30]);
172        for x in out.iter().skip(5).flatten() {
173            assert!(x.abs() < 1e-6, "expected near-zero, got {x}");
174        }
175    }
176
177    #[test]
178    fn batch_equals_streaming() {
179        let prices: Vec<f64> = (0..60)
180            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 8.0)
181            .collect();
182        let mut a = FisherTransform::new(10).unwrap();
183        let mut b = FisherTransform::new(10).unwrap();
184        let batch = a.batch(&prices);
185        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
186        assert_eq!(batch, streamed);
187    }
188
189    #[test]
190    fn ignores_non_finite_input() {
191        let mut ft = FisherTransform::new(5).unwrap();
192        ft.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
193        let before = ft.value();
194        assert!(before.is_some());
195        assert_eq!(ft.update(f64::NAN), None);
196        assert_eq!(ft.update(f64::INFINITY), None);
197    }
198
199    #[test]
200    fn reset_clears_state() {
201        let mut ft = FisherTransform::new(5).unwrap();
202        ft.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
203        assert!(ft.is_ready());
204        ft.reset();
205        assert!(!ft.is_ready());
206        assert_eq!(ft.update(1.0), None);
207    }
208}