kestrel_chartkit/indicator/
mfi.rs1use std::collections::{HashMap, VecDeque};
2
3use crate::model::Bar;
4
5use super::smoothing::{crossed_over, crossed_under, Ema, ExtremeWindow};
6use super::{Indicator, IndicatorAlert, IndicatorOutput};
7
8pub struct Mfi {
19 mfi_len: usize,
20 mid_line: f64,
21 oversold: f64,
22 overbought: f64,
23 require_extreme_zone: bool,
24
25 prev_src: Option<f64>,
26 flow_window: VecDeque<(f64, f64, f64)>,
27 pos_sum: f64,
28 neg_sum: f64,
29 vol_sum: f64,
30
31 mfi_avg: Ema,
32 signal_avg: Ema,
33 extreme_window: ExtremeWindow,
34 prev_mfi_line: Option<f64>,
35 prev_signal: Option<f64>,
36 bars_seen: usize,
37
38 alerts: MfiAlerts,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Default)]
42pub struct MfiAlerts {
43 pub bull_extreme: bool,
44 pub bear_extreme: bool,
45 pub bull_mid_cross: bool,
46 pub bear_mid_cross: bool,
47 pub extreme_strength: f64,
48}
49
50impl Mfi {
51 #[allow(clippy::too_many_arguments)]
52 pub fn new(
53 mfi_len: usize,
54 avg_len: usize,
55 sig_len: usize,
56 mid_line: f64,
57 overbought: f64,
58 oversold: f64,
59 lookback_extreme: usize,
60 require_extreme_zone: bool,
61 ) -> Self {
62 Self {
63 mfi_len,
64 mid_line,
65 oversold,
66 overbought,
67 require_extreme_zone,
68 prev_src: None,
69 flow_window: VecDeque::with_capacity(mfi_len),
70 pos_sum: 0.0,
71 neg_sum: 0.0,
72 vol_sum: 0.0,
73 mfi_avg: Ema::new(avg_len),
74 signal_avg: Ema::new(sig_len),
75 extreme_window: ExtremeWindow::new(lookback_extreme),
76 prev_mfi_line: None,
77 prev_signal: None,
78 bars_seen: 0,
79 alerts: MfiAlerts::default(),
80 }
81 }
82
83 pub fn with_defaults() -> Self {
84 Self::new(14, 3, 3, 50.0, 80.0, 20.0, 5, true)
85 }
86}
87
88impl Indicator for Mfi {
89 fn name(&self) -> &str {
90 "mfi"
91 }
92
93 fn warmup_period(&self) -> usize {
94 self.mfi_len
95 }
96
97 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
98 self.alerts = MfiAlerts::default();
99 self.bars_seen += 1;
100
101 let src = bar.typical_price();
102 let raw_flow = src * bar.volume;
103 let (pos_flow, neg_flow) = match self.prev_src {
104 None => (0.0, 0.0),
105 Some(prev) if src > prev => (raw_flow, 0.0),
106 Some(prev) if src < prev => (0.0, raw_flow),
107 Some(_) => (0.0, 0.0),
108 };
109 self.prev_src = Some(src);
110
111 if self.flow_window.len() == self.mfi_len {
112 let (old_pos, old_neg, old_vol) = self.flow_window.pop_front().unwrap();
113 self.pos_sum -= old_pos;
114 self.neg_sum -= old_neg;
115 self.vol_sum -= old_vol;
116 }
117 self.flow_window.push_back((pos_flow, neg_flow, bar.volume));
118 self.pos_sum += pos_flow;
119 self.neg_sum += neg_flow;
120 self.vol_sum += bar.volume;
121
122 if self.flow_window.len() < self.mfi_len {
123 return None;
124 }
125
126 let has_volume = self.vol_sum > 0.0;
127 let total_flow = self.pos_sum + self.neg_sum;
128 let raw_mfi = if total_flow == 0.0 {
129 50.0
130 } else if self.neg_sum == 0.0 {
131 100.0
132 } else if self.pos_sum == 0.0 {
133 0.0
134 } else {
135 100.0 - 100.0 / (1.0 + self.pos_sum / self.neg_sum)
136 };
137
138 let mfi_line = self.mfi_avg.update(raw_mfi)?;
139 let signal = self.signal_avg.update(mfi_line)?;
140
141 let extreme = self.extreme_window.push(mfi_line);
142 let was_oversold = extreme
143 .map(|(low, _)| low <= self.oversold)
144 .unwrap_or(false);
145 let was_overbought = extreme
146 .map(|(_, high)| high >= self.overbought)
147 .unwrap_or(false);
148
149 if let (Some(prev_mfi), Some(prev_sig)) = (self.prev_mfi_line, self.prev_signal) {
150 let bull_cross = crossed_over(prev_mfi, prev_sig, mfi_line, signal);
151 let bear_cross = crossed_under(prev_mfi, prev_sig, mfi_line, signal);
152 self.alerts.bull_extreme =
153 has_volume && bull_cross && (!self.require_extreme_zone || was_oversold);
154 self.alerts.bear_extreme =
155 has_volume && bear_cross && (!self.require_extreme_zone || was_overbought);
156 self.alerts.bull_mid_cross =
157 has_volume && crossed_over(prev_mfi, self.mid_line, mfi_line, self.mid_line);
158 self.alerts.bear_mid_cross =
159 has_volume && crossed_under(prev_mfi, self.mid_line, mfi_line, self.mid_line);
160
161 let (lowest, highest) = extreme.unwrap_or((mfi_line, mfi_line));
162 self.alerts.extreme_strength = if self.alerts.bull_extreme {
163 ((self.oversold - lowest) / self.oversold.abs()).clamp(0.0, 1.0)
164 } else if self.alerts.bear_extreme {
165 ((highest - self.overbought) / self.overbought.abs()).clamp(0.0, 1.0)
166 } else {
167 0.0
168 };
169 }
170 self.prev_mfi_line = Some(mfi_line);
171 self.prev_signal = Some(signal);
172
173 let mut extra = HashMap::new();
174 extra.insert("signal".to_string(), signal);
175
176 Some(IndicatorOutput::with_extra(mfi_line, extra))
177 }
178
179 fn reset(&mut self) {
180 self.prev_src = None;
181 self.flow_window.clear();
182 self.pos_sum = 0.0;
183 self.neg_sum = 0.0;
184 self.vol_sum = 0.0;
185 self.mfi_avg.reset();
186 self.signal_avg.reset();
187 self.extreme_window.reset();
188 self.prev_mfi_line = None;
189 self.prev_signal = None;
190 self.bars_seen = 0;
191 self.alerts = MfiAlerts::default();
192 }
193
194 fn alerts(&self) -> Vec<IndicatorAlert> {
195 let a = self.alerts;
196 let mut out = Vec::new();
197 if a.bull_extreme {
198 out.push(IndicatorAlert {
199 kind: "bull_extreme".to_string(),
200 note: "MFI · BULL CROSS OVERSOLD".to_string(),
201 strength: a.extreme_strength,
202 });
203 }
204 if a.bear_extreme {
205 out.push(IndicatorAlert {
206 kind: "bear_extreme".to_string(),
207 note: "MFI · BEAR CROSS OVERBOUGHT".to_string(),
208 strength: a.extreme_strength,
209 });
210 }
211 if a.bull_mid_cross {
212 out.push(IndicatorAlert {
213 kind: "bull_mid_cross".to_string(),
214 note: "MFI · CROSS ABOVE 50".to_string(),
215 strength: 1.0,
216 });
217 }
218 if a.bear_mid_cross {
219 out.push(IndicatorAlert {
220 kind: "bear_mid_cross".to_string(),
221 note: "MFI · CROSS BELOW 50".to_string(),
222 strength: 1.0,
223 });
224 }
225 out
226 }
227}