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 {
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}