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        Ok(Self {
52            period,
53            window: VecDeque::with_capacity(period),
54            smoothed: 0.0,
55            last_fisher: None,
56        })
57    }
58
59    /// Configured period.
60    pub const fn period(&self) -> usize {
61        self.period
62    }
63
64    /// Current Fisher value if available.
65    pub const fn value(&self) -> Option<f64> {
66        self.last_fisher
67    }
68}
69
70impl Indicator for FisherTransform {
71    type Input = f64;
72    type Output = f64;
73
74    fn update(&mut self, input: f64) -> Option<f64> {
75        if !input.is_finite() {
76            return self.last_fisher;
77        }
78        if self.window.len() == self.period {
79            self.window.pop_front();
80        }
81        self.window.push_back(input);
82        if self.window.len() < self.period {
83            return None;
84        }
85        let max = self
86            .window
87            .iter()
88            .copied()
89            .fold(f64::NEG_INFINITY, f64::max);
90        let min = self.window.iter().copied().fold(f64::INFINITY, f64::min);
91        let range = max - min;
92        // Normalise to roughly [-1, +1]; centred midpoint when range == 0.
93        let raw = if range > 0.0 {
94            ((input - min) / range).mul_add(2.0, -1.0)
95        } else {
96            0.0
97        };
98        // Ehlers IIR: 0.33 * raw + 0.67 * prev_smoothed, then clamp.
99        self.smoothed = 0.33f64.mul_add(raw, 0.67 * self.smoothed);
100        // Clamp strictly inside (-1, +1) to keep the log finite.
101        let clamped = self.smoothed.clamp(-0.999, 0.999);
102        let fisher = 0.5 * ((1.0 + clamped) / (1.0 - clamped)).ln();
103        self.last_fisher = Some(fisher);
104        Some(fisher)
105    }
106
107    fn reset(&mut self) {
108        self.window.clear();
109        self.smoothed = 0.0;
110        self.last_fisher = None;
111    }
112
113    fn warmup_period(&self) -> usize {
114        self.period
115    }
116
117    fn is_ready(&self) -> bool {
118        self.last_fisher.is_some()
119    }
120
121    fn name(&self) -> &'static str {
122        "FisherTransform"
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::traits::BatchExt;
130
131    #[test]
132    fn new_rejects_zero_period() {
133        assert!(matches!(FisherTransform::new(0), Err(Error::PeriodZero)));
134    }
135
136    #[test]
137    fn accessors_and_metadata() {
138        let mut ft = FisherTransform::new(10).unwrap();
139        assert_eq!(ft.period(), 10);
140        assert_eq!(ft.warmup_period(), 10);
141        assert_eq!(ft.name(), "FisherTransform");
142        assert!(ft.value().is_none());
143        for i in 1..=10 {
144            ft.update(f64::from(i));
145        }
146        assert!(ft.value().is_some());
147        assert!(ft.is_ready());
148    }
149
150    #[test]
151    fn warmup_returns_none_until_seed() {
152        let mut ft = FisherTransform::new(5).unwrap();
153        for i in 1..=4 {
154            assert_eq!(ft.update(f64::from(i)), None);
155        }
156        assert!(ft.update(5.0).is_some());
157    }
158
159    #[test]
160    fn constant_series_zero_range_yields_zero() {
161        let mut ft = FisherTransform::new(5).unwrap();
162        let out = ft.batch(&[42.0_f64; 30]);
163        for x in out.iter().skip(5).flatten() {
164            assert!(x.abs() < 1e-6, "expected near-zero, got {x}");
165        }
166    }
167
168    #[test]
169    fn batch_equals_streaming() {
170        let prices: Vec<f64> = (0..60)
171            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 8.0)
172            .collect();
173        let mut a = FisherTransform::new(10).unwrap();
174        let mut b = FisherTransform::new(10).unwrap();
175        let batch = a.batch(&prices);
176        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
177        assert_eq!(batch, streamed);
178    }
179
180    #[test]
181    fn ignores_non_finite_input() {
182        let mut ft = FisherTransform::new(5).unwrap();
183        ft.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
184        let before = ft.value();
185        assert!(before.is_some());
186        assert_eq!(ft.update(f64::NAN), before);
187        assert_eq!(ft.update(f64::INFINITY), before);
188    }
189
190    #[test]
191    fn reset_clears_state() {
192        let mut ft = FisherTransform::new(5).unwrap();
193        ft.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
194        assert!(ft.is_ready());
195        ft.reset();
196        assert!(!ft.is_ready());
197        assert_eq!(ft.update(1.0), None);
198    }
199}