Skip to main content

egui_charts/studies/builtin/
fisher_transform.rs

1use crate::model::Bar;
2use crate::studies::palette;
3use crate::studies::stepper::Stepper;
4use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
5use egui::Color32;
6
7/// Fisher Transform
8/// Converts prices into a Gaussian normal distribution
9/// Creates sharper turning points for identifying reversals
10#[derive(Clone)]
11pub struct FisherTransform {
12    /// Lookback period
13    period: usize,
14    values: IndicatorValues,
15    stepper: Stepper<(std::collections::VecDeque<f64>, f64, f64, bool)>,
16    /// Colors: [Fisher, Signal/Trigger]
17    colors: Vec<Color32>,
18    visible: bool,
19}
20
21impl FisherTransform {
22    /// Create with a lookback of `period` bars, clamped to at least 1.
23    pub fn new(period: usize) -> Self {
24        Self {
25            period: period.max(1),
26            values: IndicatorValues::new(),
27            stepper: Stepper::default(),
28            colors: vec![
29                palette::BLUE,   // Blue - Fisher
30                palette::ORANGE, // Orange - Signal
31            ],
32            visible: true,
33        }
34    }
35
36    /// Set plot colors in the order returned by [`Indicator::line_names`].
37    pub fn with_colors(mut self, colors: Vec<Color32>) -> Self {
38        self.colors = colors;
39        self
40    }
41}
42
43/// Construct with the conventional default parameters.
44impl Default for FisherTransform {
45    fn default() -> Self {
46        Self::new(9)
47    }
48}
49
50impl Indicator for FisherTransform {
51    fn source(&self) -> Option<crate::studies::StudySource> {
52        Some(crate::studies::StudySource::Price(
53            crate::model::PriceSource::HL2,
54        ))
55    }
56
57    crate::studies::inputs::study_inputs!(self;
58        period: length("Period", 1, usize::MAX)
59    ; {});
60
61    fn name(&self) -> &str {
62        "Fisher Transform"
63    }
64
65    fn desc(&self) -> &str {
66        "Fisher Transform - Identifies price reversals with Gaussian distribution"
67    }
68
69    fn calculate(&mut self, data: &[Bar]) {
70        self.update(data, 0);
71    }
72
73    fn update(&mut self, data: &[Bar], from: usize) {
74        self.stepper
75            .run(data, from, &mut self.values, |state, bar| {
76                if !bar.high.is_finite() || !bar.low.is_finite() {
77                    return IndicatorValue::None;
78                }
79                let price = (bar.high + bar.low) / 2.0;
80                state.0.push_back(price);
81                if state.0.len() > self.period {
82                    state.0.pop_front();
83                }
84                if state.0.len() < self.period {
85                    return IndicatorValue::None;
86                }
87                let high = state.0.iter().copied().fold(f64::NEG_INFINITY, f64::max);
88                let low = state.0.iter().copied().fold(f64::INFINITY, f64::min);
89                let range = high - low;
90                let normalized = if range > 0.0 {
91                    2.0 * ((price - low) / range - 0.5)
92                } else {
93                    0.0
94                };
95                let mut value = 0.33 * normalized + 0.67 * state.1;
96                if value > 0.99 {
97                    value = 0.999;
98                } else if value < -0.99 {
99                    value = -0.999;
100                }
101                let signal = if state.3 { state.2 } else { f64::NAN };
102                state.2 = 0.5 * ((1.0 + value) / (1.0 - value)).ln() + 0.5 * state.2;
103                state.1 = value;
104                state.3 = true;
105                if state.2.is_nan() {
106                    IndicatorValue::None
107                } else {
108                    IndicatorValue::Multiple(vec![state.2, signal])
109                }
110            });
111    }
112
113    fn is_incremental(&self) -> bool {
114        true
115    }
116
117    fn values(&self) -> &IndicatorValues {
118        &self.values
119    }
120
121    fn colors(&self) -> Vec<Color32> {
122        self.colors.clone()
123    }
124
125    fn set_colors(&mut self, colors: Vec<Color32>) {
126        if colors.len() >= 2 {
127            self.colors = colors;
128        }
129    }
130
131    fn is_overlay(&self) -> bool {
132        false // Separate pane
133    }
134
135    fn is_visible(&self) -> bool {
136        self.visible
137    }
138
139    fn set_visible(&mut self, visible: bool) {
140        self.visible = visible;
141    }
142
143    fn clone_box(&self) -> Box<dyn Indicator> {
144        Box::new(self.clone())
145    }
146
147    fn line_names(&self) -> Vec<String> {
148        vec![format!("Fisher({})", self.period), "Signal".to_string()]
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::studies::test_support::make_range_bar as make_bar;
156
157    /// (high, low, expected (fisher, trigger)) for length 5, from Ehlers'
158    /// published construction computed independently. The series rises long
159    /// enough for the smoothed value to reach the clamp, stays flat until the
160    /// window has no range, then falls. The first value has no trigger.
161    #[allow(clippy::type_complexity)]
162    const REFERENCE: &[(f64, f64, Option<(f64, f64)>)] = &[
163        (101.0, 99.0, None),
164        (102.0, 100.0, None),
165        (103.0, 101.0, None),
166        (104.0, 102.0, None),
167        (105.0, 103.0, Some((0.342828254415394, f64::NAN))),
168        (106.0, 104.0, Some((0.791373872129106, 0.342828254415394))),
169        (107.0, 105.0, Some((1.26149294935091, 0.791373872129106))),
170        (108.0, 106.0, Some((1.7251749834508, 1.26149294935091))),
171        (109.0, 107.0, Some((2.17540858067979, 1.7251749834508))),
172        (110.0, 108.0, Some((2.61256860142716, 2.17540858067979))),
173        (111.0, 109.0, Some((3.03914327264257, 2.61256860142716))),
174        (112.0, 110.0, Some((3.45779932767405, 3.03914327264257))),
175        (113.0, 111.0, Some((3.87077398411298, 3.45779932767405))),
176        (114.0, 112.0, Some((4.27977042664787, 3.87077398411298))),
177        (114.0, 112.0, Some((4.6860228060836, 4.27977042664787))),
178        (114.0, 112.0, Some((6.143212570292, 4.6860228060836))),
179        (114.0, 112.0, Some((6.8718074523962, 6.143212570292))),
180        (114.0, 112.0, Some((4.2454320902449, 6.8718074523962))),
181        (114.0, 112.0, Some((2.60547582289472, 4.2454320902449))),
182        (114.0, 112.0, Some((1.61276554585803, 2.60547582289472))),
183        (112.0, 110.0, Some((0.676974906197813, 1.61276554585803))),
184        (110.0, 108.0, Some((-0.104626787889589, 0.676974906197813))),
185        (108.0, 106.0, Some((-0.759435607263236, -0.104626787889589))),
186        (106.0, 104.0, Some((-1.32566396088824, -0.759435607263236))),
187        (104.0, 102.0, Some((-1.83329744102957, -1.32566396088824))),
188    ];
189
190    #[test]
191    fn matches_published_reference() {
192        let data: Vec<Bar> = REFERENCE
193            .iter()
194            .map(|&(h, l, _)| make_bar(h, l, (h + l) / 2.0))
195            .collect();
196        let mut ft = FisherTransform::new(5);
197        ft.calculate(&data);
198
199        assert_eq!(ft.values.len(), REFERENCE.len());
200        for (i, (value, &(_, _, expected))) in ft.values.to_rows().iter().zip(REFERENCE).enumerate()
201        {
202            let actual = match value {
203                IndicatorValue::Multiple(v) => Some((v[0], v[1])),
204                _ => None,
205            };
206            let near = |a: f64, b: f64| (a - b).abs() < 1e-9 || a.is_nan() && b.is_nan();
207            match (actual, expected) {
208                (None, None) => {}
209                (Some((f, t)), Some((ef, et))) => assert!(
210                    near(f, ef) && near(t, et),
211                    "bar {i}: got ({f}, {t}), expected ({ef}, {et})"
212                ),
213                _ => panic!("bar {i}: got {actual:?}, expected {expected:?}"),
214            }
215        }
216    }
217}