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
8/// Money Flow Index, smoothed, with a signal line.
9///
10/// Per bar the raw money flow is `typical * volume`, `typical = (high + low + close) / 3`; it counts
11/// as positive when the typical price rose against the previous bar, negative when it fell, and not
12/// at all on the first bar or when unchanged. Over the last `mfi_len` bars
13/// `raw = 100 - 100 / (1 + positive / negative)`, with `50` without any flow, `100` without negative
14/// and `0` without positive flow. **`value` is the line**, `Ema(avg_len)` of the raw MFI with the
15/// first-sample seed; `extra["signal"]` is `Ema(sig_len)` of the line. Alerts require volume.
16///
17/// First output: with the `mfi_len`-th bar. [`Indicator::reset`] clears the window and averages.
18pub 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}