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