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 with Ehlers' own output smoothing,
13/// `Fisher_t = 0.5 * ln((1+x)/(1-x)) + 0.5 * Fisher_{t-1}`. The result has a
14/// near-Gaussian distribution, so extreme readings stand out cleanly. A
15/// secondary signal is produced by lagging the Fisher value by one bar (the
16/// classic trigger), making the indicator a two-line crossover system in
17/// charts.
18///
19/// Only the primary Fisher value is exposed here as a scalar; the lagged
20/// trigger is one update behind by construction.
21///
22/// # Example
23///
24/// ```
25/// use wickra_core::{Indicator, FisherTransform};
26///
27/// let mut ft = FisherTransform::new(10).unwrap();
28/// let mut last = None;
29/// for i in 0..30 {
30///     last = ft.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
31/// }
32/// assert!(last.is_some());
33/// ```
34#[derive(Debug, Clone)]
35pub struct FisherTransform {
36    period: usize,
37    window: VecDeque<f64>,
38    smoothed: f64,
39    last_fisher: Option<f64>,
40}
41
42impl FisherTransform {
43    /// Construct with the rolling extrema window length.
44    ///
45    /// # Errors
46    ///
47    /// Returns [`Error::PeriodZero`] if `period == 0`.
48    pub fn new(period: usize) -> Result<Self> {
49        if period == 0 {
50            return Err(Error::PeriodZero);
51        }
52        if period > crate::error::MAX_PERIOD {
53            return Err(Error::InvalidPeriod {
54                message: crate::error::PERIOD_ABOVE_MAX,
55            });
56        }
57        Ok(Self {
58            period,
59            window: VecDeque::with_capacity(period),
60            smoothed: 0.0,
61            last_fisher: None,
62        })
63    }
64
65    /// Configured period.
66    pub const fn period(&self) -> usize {
67        self.period
68    }
69
70    /// Current Fisher value if available.
71    pub const fn value(&self) -> Option<f64> {
72        self.last_fisher
73    }
74}
75
76impl Indicator for FisherTransform {
77    type Input = f64;
78    type Output = f64;
79
80    #[inline]
81    fn update(&mut self, input: f64) -> Option<f64> {
82        if !input.is_finite() {
83            return None;
84        }
85        if self.window.len() == self.period {
86            self.window.pop_front();
87        }
88        self.window.push_back(input);
89        if self.window.len() < self.period {
90            return None;
91        }
92        let max = self
93            .window
94            .iter()
95            .copied()
96            .fold(f64::NEG_INFINITY, f64::max);
97        let min = self.window.iter().copied().fold(f64::INFINITY, f64::min);
98        let range = max - min;
99        // Normalise to roughly [-1, +1]; centred midpoint when range == 0.
100        let raw = if range > 0.0 {
101            ((input - min) / range).mul_add(2.0, -1.0)
102        } else {
103            0.0
104        };
105        // Ehlers IIR: 0.33 * raw + 0.67 * prev_smoothed, then clamp. The clamped
106        // value is what recurs, as in Ehlers' code (Value1 is overwritten).
107        let clamped = 0.33f64
108            .mul_add(raw, 0.67 * self.smoothed)
109            .clamp(-0.999, 0.999);
110        self.smoothed = clamped;
111        // Fisher transform plus Ehlers' half-weight carry of the previous value.
112        let prev = self.last_fisher.unwrap_or(0.0);
113        let fisher = 0.5f64.mul_add(((1.0 + clamped) / (1.0 - clamped)).ln(), 0.5 * prev);
114        self.last_fisher = Some(fisher);
115        Some(fisher)
116    }
117
118    fn reset(&mut self) {
119        self.window.clear();
120        self.smoothed = 0.0;
121        self.last_fisher = None;
122    }
123
124    #[inline]
125    fn warmup_period(&self) -> usize {
126        self.period
127    }
128
129    #[inline]
130    fn is_ready(&self) -> bool {
131        self.last_fisher.is_some()
132    }
133
134    #[inline]
135    fn name(&self) -> &'static str {
136        "FisherTransform"
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::traits::BatchExt;
144    use approx::assert_relative_eq;
145
146    #[test]
147    fn new_rejects_zero_period() {
148        assert!(matches!(FisherTransform::new(0), Err(Error::PeriodZero)));
149    }
150
151    #[test]
152    fn accessors_and_metadata() {
153        let mut ft = FisherTransform::new(10).unwrap();
154        assert_eq!(ft.period(), 10);
155        assert_eq!(ft.warmup_period(), 10);
156        assert_eq!(ft.name(), "FisherTransform");
157        assert!(ft.value().is_none());
158        for i in 1..=10 {
159            ft.update(f64::from(i));
160        }
161        assert!(ft.value().is_some());
162        assert!(ft.is_ready());
163    }
164
165    #[test]
166    fn warmup_returns_none_until_seed() {
167        let mut ft = FisherTransform::new(5).unwrap();
168        for i in 1..=4 {
169            assert_eq!(ft.update(f64::from(i)), None);
170        }
171        assert!(ft.update(5.0).is_some());
172    }
173
174    #[test]
175    fn constant_series_zero_range_yields_zero() {
176        let mut ft = FisherTransform::new(5).unwrap();
177        let out = ft.batch(&[42.0_f64; 30]);
178        for x in out.iter().skip(5).flatten() {
179            assert!(x.abs() < 1e-6, "expected near-zero, got {x}");
180        }
181    }
182
183    #[test]
184    fn batch_equals_streaming() {
185        let prices: Vec<f64> = (0..60)
186            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 8.0)
187            .collect();
188        let mut a = FisherTransform::new(10).unwrap();
189        let mut b = FisherTransform::new(10).unwrap();
190        let batch = a.batch(&prices);
191        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
192        assert_eq!(batch, streamed);
193    }
194
195    #[test]
196    fn ignores_non_finite_input() {
197        let mut ft = FisherTransform::new(5).unwrap();
198        ft.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
199        let before = ft.value();
200        assert!(before.is_some());
201        assert_eq!(ft.update(f64::NAN), None);
202        assert_eq!(ft.update(f64::INFINITY), None);
203    }
204
205    #[test]
206    fn reset_clears_state() {
207        let mut ft = FisherTransform::new(5).unwrap();
208        ft.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
209        assert!(ft.is_ready());
210        ft.reset();
211        assert!(!ft.is_ready());
212        assert_eq!(ft.update(1.0), None);
213    }
214
215    #[test]
216    fn rejects_period_above_maximum() {
217        assert!(matches!(
218            FisherTransform::new(crate::error::MAX_PERIOD + 1),
219            Err(Error::InvalidPeriod { .. })
220        ));
221    }
222
223    #[test]
224    fn first_value_lands_exactly_at_warmup_minus_one() {
225        let prices: Vec<f64> = (0..40)
226            .map(|i| 50.0 + (f64::from(i) * 0.4).sin() * 3.0)
227            .collect();
228        for period in [1usize, 4, 9] {
229            let mut ft = FisherTransform::new(period).unwrap();
230            let out = ft.batch(&prices);
231            let warm = ft.warmup_period();
232            assert!(out[..warm - 1].iter().all(Option::is_none));
233            assert!(out[warm - 1].is_some());
234        }
235    }
236
237    #[test]
238    fn hand_computed_recursive_values() {
239        let mut ft = FisherTransform::new(3).unwrap();
240        assert_eq!(ft.update(1.0), None);
241        assert_eq!(ft.update(2.0), None);
242        // Window [1, 2, 3], input 3 is the max -> raw = 2 * 1 - 1 = 1.
243        // x1 = 0.33 * 1 + 0.67 * 0 = 0.33
244        // F1 = 0.5 * ln(1.33 / 0.67) + 0.5 * 0 = 0.342_828_254...
245        let f1 = ft.update(3.0).unwrap();
246        assert_relative_eq!(f1, 0.5 * (1.33f64 / 0.67).ln(), epsilon = 1e-12);
247        assert_relative_eq!(f1, 0.342_828_254_415_393_8, epsilon = 1e-12);
248        // Window [2, 3, 3], input 3 is the max -> raw = 1.
249        // x2 = 0.33 + 0.67 * 0.33 = 0.5511
250        // F2 = 0.5 * ln(1.5511 / 0.4489) + 0.5 * F1 = 0.791_373_872...
251        let f2 = ft.update(3.0).unwrap();
252        assert_relative_eq!(
253            f2,
254            0.5 * (1.5511f64 / 0.4489).ln() + 0.5 * f1,
255            epsilon = 1e-12
256        );
257        assert_relative_eq!(f2, 0.791_373_872_129_106_3, epsilon = 1e-12);
258        // Window [3, 3, 1], input 1 is the min -> raw = -1.
259        // x3 = -0.33 + 0.67 * 0.5511 = 0.039_237
260        // F3 = 0.5 * ln(1.039_237 / 0.960_763) + 0.5 * F2 = 0.434_944_090...
261        let f3 = ft.update(1.0).unwrap();
262        assert_relative_eq!(f3, 0.434_944_090_356_889_4, epsilon = 1e-12);
263        assert_eq!(ft.value(), Some(f3));
264    }
265
266    #[test]
267    fn clamped_value_is_what_recurs() {
268        // A strictly rising series keeps raw = 1, so the smoothed value climbs
269        // toward 1 and is clamped at 0.999; the clamped value is stored.
270        let mut ft = FisherTransform::new(3).unwrap();
271        for i in 0..60 {
272            ft.update(f64::from(i));
273        }
274        assert_eq!(ft.smoothed.to_bits(), 0.999f64.to_bits());
275        let prev = ft.value().unwrap();
276        // Steady state: F = 0.5 * ln(1.999 / 0.001) + 0.5 * F -> F -> ln(1999) = 7.600_402.
277        assert_relative_eq!(prev, 1999f64.ln(), epsilon = 1e-9);
278        // A drop to the window minimum: raw = -1,
279        // x = -0.33 + 0.67 * 0.999 = 0.339_33 (recurring from the clamped 0.999).
280        let next = ft.update(-100.0).unwrap();
281        assert_relative_eq!(ft.smoothed, 0.339_33, epsilon = 1e-12);
282        assert_relative_eq!(
283            next,
284            0.5 * (1.339_33f64 / 0.660_67).ln() + 0.5 * prev,
285            epsilon = 1e-12
286        );
287    }
288
289    #[test]
290    fn falling_series_clamps_at_lower_bound() {
291        let mut ft = FisherTransform::new(3).unwrap();
292        for i in 0..60 {
293            ft.update(-f64::from(i));
294        }
295        assert_eq!(ft.smoothed.to_bits(), (-0.999f64).to_bits());
296        assert_relative_eq!(ft.value().unwrap(), -(1999f64.ln()), epsilon = 1e-9);
297    }
298
299    #[test]
300    fn flat_window_decays_previous_reading() {
301        // range == 0 -> raw = 0: x_t = 0.67 * x_{t-1}, and F carries half of its
302        // previous value, so a flat stretch decays toward zero.
303        let mut ft = FisherTransform::new(2).unwrap();
304        ft.update(1.0);
305        let f1 = ft.update(2.0).unwrap();
306        // Window [2, 2]: raw = 0 -> x = 0.67 * 0.33 = 0.2211.
307        let f2 = ft.update(2.0).unwrap();
308        assert_relative_eq!(
309            f2,
310            0.5 * (1.2211f64 / 0.7789).ln() + 0.5 * f1,
311            epsilon = 1e-12
312        );
313        // F2 = 0.5 * 0.449_6 + 0.5 * 0.342_8 = 0.396; then a long flat stretch decays.
314        assert!(f2 > f1);
315        let tail = ft.batch(&[2.0; 40]);
316        assert!(tail.iter().flatten().all(|v| *v > 0.0));
317        assert!(ft.value().unwrap() < 1e-6);
318    }
319
320    #[test]
321    fn reset_replays_identically_and_batch_nan_into_matches() {
322        let prices: Vec<f64> = (0..80)
323            .map(|i| 100.0 + (f64::from(i) * 0.27).sin() * 6.0 + f64::from(i % 3))
324            .collect();
325        let mut ft = FisherTransform::new(8).unwrap();
326        let first = ft.batch(&prices);
327        ft.reset();
328        let second = ft.batch(&prices);
329        assert_eq!(first, second);
330        assert_eq!(second, FisherTransform::new(8).unwrap().batch(&prices));
331        let mut out = vec![0.0; prices.len()];
332        FisherTransform::new(8)
333            .unwrap()
334            .batch_nan_into(&prices, &mut out);
335        assert!(out
336            .iter()
337            .zip(&first)
338            .all(|(a, b)| a.to_bits() == b.unwrap_or(f64::NAN).to_bits()));
339    }
340}