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