kestrel_chartkit/indicator/
fisher_transform.rs1use 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 {
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}