Skip to main content

kestrel_chartkit/indicator/
fisher_transform.rs

1use std::collections::{HashMap, VecDeque};
2
3use crate::model::Bar;
4
5use super::divergence::SlopeDivergence;
6use super::smoothing::{crossed_over, crossed_under, Ema, ExtremeWindow};
7use super::{Indicator, IndicatorAlert, IndicatorOutput};
8
9/// Fisher Transform of the median price, smoothed, with a signal and a context line.
10///
11/// Over the last `fish_len` values of `(high + low) / 2`, both recursions starting at 0:
12///
13/// ```text
14/// v    = clamp(0.66 * ((x - lowest) / (highest - lowest) - 0.5) + 0.67 * v_prev, -0.999, 0.999)
15/// fish = 0.5 * ln((1 + v) / (1 - v)) + 0.5 * fish_prev
16/// ```
17///
18/// (the normalised term is `0` for a flat window). **`value` is the line**, `Ema(avg_len)` of `fish`
19/// with the first-sample seed; `extra["signal"]` is `Ema(sig_len)` of the line. The registry fixes
20/// `avg_len = 2` and `sig_len = 3` and reads only `fish_len`.
21///
22/// First output: with the `fish_len`-th bar. [`Indicator::reset`] clears windows, recursions and
23/// averages.
24pub struct FisherTransform {
25    fish_len: usize,
26    mid_line: f64,
27    oversold: f64,
28    overbought: f64,
29    require_extreme_zone: bool,
30    ctx_len: usize,
31
32    src_window: VecDeque<f64>,
33    prev_value: Option<f64>,
34    prev_fish: Option<f64>,
35
36    avg: Ema,
37    signal_avg: Ema,
38    extreme_window: ExtremeWindow,
39    prev_fish_line: Option<f64>,
40    prev_signal: Option<f64>,
41    bars_seen: usize,
42
43    ctx_window: VecDeque<f64>,
44    ctx_prev_value: Option<f64>,
45    ctx_prev_fish: Option<f64>,
46    ctx_avg: Ema,
47    divergence: SlopeDivergence,
48
49    alerts: FisherAlerts,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Default)]
53pub struct FisherAlerts {
54    pub bull_extreme: bool,
55    pub bear_extreme: bool,
56    pub bull_mid_cross: bool,
57    pub bear_mid_cross: bool,
58    pub bull_divergence: bool,
59    pub bear_divergence: bool,
60    pub extreme_strength: f64,
61    pub divergence_strength: f64,
62}
63
64impl FisherTransform {
65    #[allow(clippy::too_many_arguments)]
66    pub fn new(
67        fish_len: usize,
68        avg_len: usize,
69        sig_len: usize,
70        mid_line: f64,
71        overbought: f64,
72        oversold: f64,
73        lookback_extreme: usize,
74        require_extreme_zone: bool,
75        ctx_len: usize,
76        div_len: usize,
77        div_min: f64,
78    ) -> Self {
79        Self {
80            fish_len,
81            mid_line,
82            oversold,
83            overbought,
84            require_extreme_zone,
85            ctx_len,
86            src_window: VecDeque::with_capacity(fish_len),
87            prev_value: None,
88            prev_fish: None,
89            avg: Ema::new(avg_len),
90            signal_avg: Ema::new(sig_len),
91            extreme_window: ExtremeWindow::new(lookback_extreme),
92            prev_fish_line: None,
93            prev_signal: None,
94            bars_seen: 0,
95            ctx_window: VecDeque::with_capacity(ctx_len),
96            ctx_prev_value: None,
97            ctx_prev_fish: None,
98            ctx_avg: Ema::new(avg_len),
99            divergence: SlopeDivergence::new(div_len, div_min),
100            alerts: FisherAlerts::default(),
101        }
102    }
103
104    pub fn with_defaults() -> Self {
105        Self::new(10, 2, 3, 0.0, 1.5, -1.5, 5, true, 40, 4, 0.5)
106    }
107}
108
109impl Indicator for FisherTransform {
110    fn name(&self) -> &str {
111        "fisher_transform"
112    }
113
114    fn warmup_period(&self) -> usize {
115        self.fish_len.max(self.ctx_len)
116    }
117
118    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
119        self.alerts = FisherAlerts::default();
120        self.bars_seen += 1;
121
122        let src = (bar.high + bar.low) / 2.0;
123
124        if self.ctx_window.len() == self.ctx_len {
125            self.ctx_window.pop_front();
126        }
127        self.ctx_window.push_back(src);
128        let ctx_line = if self.ctx_window.len() == self.ctx_len {
129            let ctx_highest = self
130                .ctx_window
131                .iter()
132                .cloned()
133                .fold(f64::NEG_INFINITY, f64::max);
134            let ctx_lowest = self
135                .ctx_window
136                .iter()
137                .cloned()
138                .fold(f64::INFINITY, f64::min);
139            let ctx_range = ctx_highest - ctx_lowest;
140            let ctx_normalized = if ctx_range != 0.0 {
141                (src - ctx_lowest) / ctx_range - 0.5
142            } else {
143                0.0
144            };
145            let ctx_prev_value = self.ctx_prev_value.unwrap_or(0.0);
146            let ctx_value = (0.66 * ctx_normalized + 0.67 * ctx_prev_value).clamp(-0.999, 0.999);
147            let ctx_prev_fish = self.ctx_prev_fish.unwrap_or(0.0);
148            let ctx_raw = 0.5 * ((1.0 + ctx_value) / (1.0 - ctx_value)).ln() + 0.5 * ctx_prev_fish;
149            self.ctx_prev_value = Some(ctx_value);
150            self.ctx_prev_fish = Some(ctx_raw);
151            self.ctx_avg.update(ctx_raw)
152        } else {
153            None
154        };
155
156        if self.src_window.len() == self.fish_len {
157            self.src_window.pop_front();
158        }
159        self.src_window.push_back(src);
160        if self.src_window.len() < self.fish_len {
161            return None;
162        }
163
164        let highest_src = self
165            .src_window
166            .iter()
167            .cloned()
168            .fold(f64::NEG_INFINITY, f64::max);
169        let lowest_src = self
170            .src_window
171            .iter()
172            .cloned()
173            .fold(f64::INFINITY, f64::min);
174        let range_src = highest_src - lowest_src;
175        let normalized = if range_src != 0.0 {
176            (src - lowest_src) / range_src - 0.5
177        } else {
178            0.0
179        };
180
181        let prev_value = self.prev_value.unwrap_or(0.0);
182        let value = (0.66 * normalized + 0.67 * prev_value).clamp(-0.999, 0.999);
183        let prev_fish = self.prev_fish.unwrap_or(0.0);
184        let fish_raw = 0.5 * ((1.0 + value) / (1.0 - value)).ln() + 0.5 * prev_fish;
185        self.prev_value = Some(value);
186        self.prev_fish = Some(fish_raw);
187
188        let fish_line = self.avg.update(fish_raw)?;
189        let signal = self.signal_avg.update(fish_line)?;
190
191        let extreme = self.extreme_window.push(fish_line);
192        let was_oversold = extreme
193            .map(|(low, _)| low <= self.oversold)
194            .unwrap_or(false);
195        let was_overbought = extreme
196            .map(|(_, high)| high >= self.overbought)
197            .unwrap_or(false);
198
199        if let (Some(prev_fish_line), Some(prev_sig)) = (self.prev_fish_line, self.prev_signal) {
200            let bull_cross = crossed_over(prev_fish_line, prev_sig, fish_line, signal);
201            let bear_cross = crossed_under(prev_fish_line, prev_sig, fish_line, signal);
202            self.alerts.bull_extreme = bull_cross && (!self.require_extreme_zone || was_oversold);
203            self.alerts.bear_extreme = bear_cross && (!self.require_extreme_zone || was_overbought);
204            self.alerts.bull_mid_cross =
205                crossed_over(prev_fish_line, self.mid_line, fish_line, self.mid_line);
206            self.alerts.bear_mid_cross =
207                crossed_under(prev_fish_line, self.mid_line, fish_line, self.mid_line);
208
209            self.alerts.extreme_strength = if let Some((low, high)) = extreme {
210                if self.alerts.bull_extreme {
211                    ((self.oversold - low) / self.oversold.abs()).clamp(0.0, 1.0)
212                } else if self.alerts.bear_extreme {
213                    ((high - self.overbought) / self.overbought.abs()).clamp(0.0, 1.0)
214                } else {
215                    0.0
216                }
217            } else {
218                0.0
219            };
220        }
221        self.prev_fish_line = Some(fish_line);
222        self.prev_signal = Some(signal);
223
224        let mut extra = HashMap::new();
225        extra.insert("signal".to_string(), signal);
226        if let Some(ctx_line) = ctx_line {
227            let div = self.divergence.update(fish_line, ctx_line);
228            self.alerts.bull_divergence = div.bull;
229            self.alerts.bear_divergence = div.bear;
230            self.alerts.divergence_strength = if div.bull || div.bear {
231                ((div.fast_dir.abs() - self.divergence.div_min()) / self.divergence.div_min())
232                    .clamp(0.0, 1.0)
233            } else {
234                0.0
235            };
236            extra.insert("ctx".to_string(), ctx_line);
237        }
238
239        Some(IndicatorOutput::with_extra(fish_line, extra))
240    }
241
242    fn reset(&mut self) {
243        self.src_window.clear();
244        self.prev_value = None;
245        self.prev_fish = None;
246        self.avg.reset();
247        self.signal_avg.reset();
248        self.extreme_window.reset();
249        self.prev_fish_line = None;
250        self.prev_signal = None;
251        self.bars_seen = 0;
252        self.ctx_window.clear();
253        self.ctx_prev_value = None;
254        self.ctx_prev_fish = None;
255        self.ctx_avg.reset();
256        self.divergence.reset();
257        self.alerts = FisherAlerts::default();
258    }
259
260    fn alerts(&self) -> Vec<IndicatorAlert> {
261        let a = self.alerts;
262        let mut out = Vec::new();
263        if a.bull_extreme {
264            out.push(IndicatorAlert {
265                kind: "bull_extreme".to_string(),
266                note: "FISH · BULL CROSS OVERSOLD".to_string(),
267                strength: a.extreme_strength,
268            });
269        }
270        if a.bear_extreme {
271            out.push(IndicatorAlert {
272                kind: "bear_extreme".to_string(),
273                note: "FISH · BEAR CROSS OVERBOUGHT".to_string(),
274                strength: a.extreme_strength,
275            });
276        }
277        if a.bull_mid_cross {
278            out.push(IndicatorAlert {
279                kind: "bull_mid_cross".to_string(),
280                note: "FISH · CROSS ABOVE ZERO".to_string(),
281                strength: 1.0,
282            });
283        }
284        if a.bear_mid_cross {
285            out.push(IndicatorAlert {
286                kind: "bear_mid_cross".to_string(),
287                note: "FISH · CROSS BELOW ZERO".to_string(),
288                strength: 1.0,
289            });
290        }
291        if a.bull_divergence {
292            out.push(IndicatorAlert {
293                kind: "bull_divergence".to_string(),
294                note: "FISHER · BULL DIVERGENCE".to_string(),
295                strength: a.divergence_strength,
296            });
297        }
298        if a.bear_divergence {
299            out.push(IndicatorAlert {
300                kind: "bear_divergence".to_string(),
301                note: "FISHER · BEAR DIVERGENCE".to_string(),
302                strength: a.divergence_strength,
303            });
304        }
305        out
306    }
307}