Skip to main content

kestrel_chartkit/indicator/
mfi.rs

1use 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}