Skip to main content

mantis_ta/indicators/momentum/
mfi.rs

1use crate::indicators::Indicator;
2use crate::types::Candle;
3use crate::utils::ringbuf::RingBuf;
4
5/// Money Flow Index — a volume-weighted RSI.
6///
7/// Typical Price = (High + Low + Close) / 3
8/// Raw Money Flow = Typical Price * Volume
9/// Each bar's raw money flow is classified positive if typical price rose
10/// versus the prior bar, negative if it fell, and excluded from both sums
11/// if typical price is unchanged. MFI sums positive and negative money flow
12/// over a rolling `period`-bar window:
13///
14/// Money Ratio = (sum of positive money flow) / (sum of negative money flow)
15/// MFI = 100 - 100 / (1 + Money Ratio)
16///
17/// # Examples
18/// ```rust
19/// use mantis_ta::indicators::{Indicator, MFI};
20/// use mantis_ta::types::Candle;
21///
22/// let candles: Vec<Candle> = [
23///     (10.0, 11.0, 9.0, 100.0),
24///     (11.0, 12.0, 10.0, 110.0),
25///     (12.0, 13.0, 11.0, 120.0),
26///     (11.0, 12.0, 10.0, 90.0),
27///     (10.0, 11.0, 9.0, 95.0),
28/// ]
29/// .iter()
30/// .enumerate()
31/// .map(|(i, (o, h, l, v))| Candle {
32///     timestamp: i as i64,
33///     open: *o,
34///     high: *h,
35///     low: *l,
36///     close: *o,
37///     volume: *v,
38/// })
39/// .collect();
40///
41/// let values = MFI::new(3).calculate(&candles);
42/// // Warmup: period + 1 bars before first value
43/// assert!(values.iter().take(3).all(|v| v.is_none()));
44/// assert!(values[3].is_some());
45/// ```
46#[derive(Debug, Clone)]
47pub struct MFI {
48    period: usize,
49    prev_tp: Option<f64>,
50    flow_window: RingBuf<(f64, f64)>,
51    pos_sum: f64,
52    neg_sum: f64,
53}
54
55impl MFI {
56    pub fn new(period: usize) -> Self {
57        assert!(period > 0, "period must be > 0");
58        Self {
59            period,
60            prev_tp: None,
61            flow_window: RingBuf::new(period, (0.0, 0.0)),
62            pos_sum: 0.0,
63            neg_sum: 0.0,
64        }
65    }
66
67    #[inline]
68    fn compute_mfi(pos_sum: f64, neg_sum: f64) -> f64 {
69        if neg_sum == 0.0 {
70            return 100.0;
71        }
72        let money_ratio = pos_sum / neg_sum;
73        100.0 - 100.0 / (1.0 + money_ratio)
74    }
75
76    #[inline]
77    fn update(&mut self, high: f64, low: f64, close: f64, volume: f64) -> Option<f64> {
78        let typical_price = (high + low + close) / 3.0;
79        let Some(prev) = self.prev_tp else {
80            self.prev_tp = Some(typical_price);
81            return None;
82        };
83        self.prev_tp = Some(typical_price);
84
85        let raw_flow = typical_price * volume;
86        let (pos, neg) = match typical_price.partial_cmp(&prev) {
87            Some(std::cmp::Ordering::Greater) => (raw_flow, 0.0),
88            Some(std::cmp::Ordering::Less) => (0.0, raw_flow),
89            _ => (0.0, 0.0),
90        };
91
92        if let Some((old_pos, old_neg)) = self.flow_window.push((pos, neg)) {
93            self.pos_sum -= old_pos;
94            self.neg_sum -= old_neg;
95        }
96        self.pos_sum += pos;
97        self.neg_sum += neg;
98
99        if self.flow_window.len() < self.period {
100            None
101        } else {
102            Some(Self::compute_mfi(self.pos_sum, self.neg_sum))
103        }
104    }
105}
106
107impl Indicator for MFI {
108    type Output = f64;
109
110    fn next(&mut self, candle: &Candle) -> Option<Self::Output> {
111        self.update(candle.high, candle.low, candle.close, candle.volume)
112    }
113
114    fn reset(&mut self) {
115        self.prev_tp = None;
116        self.flow_window = RingBuf::new(self.period, (0.0, 0.0));
117        self.pos_sum = 0.0;
118        self.neg_sum = 0.0;
119    }
120
121    fn warmup_period(&self) -> usize {
122        self.period + 1
123    }
124
125    fn clone_boxed(&self) -> Box<dyn Indicator<Output = Self::Output>> {
126        Box::new(self.clone())
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    fn candle(h: f64, l: f64, c: f64, v: f64) -> Candle {
135        Candle {
136            timestamp: 0,
137            open: c,
138            high: h,
139            low: l,
140            close: c,
141            volume: v,
142        }
143    }
144
145    #[test]
146    fn mfi_warmup_period_is_period_plus_one() {
147        let mfi = MFI::new(14);
148        assert_eq!(mfi.warmup_period(), 15);
149    }
150
151    #[test]
152    fn mfi_emits_none_until_warmup() {
153        let mut mfi = MFI::new(3);
154        let candles = vec![
155            candle(11.0, 9.0, 10.0, 100.0),
156            candle(12.0, 10.0, 11.0, 110.0),
157            candle(13.0, 11.0, 12.0, 120.0),
158        ];
159        let outputs: Vec<_> = candles.iter().map(|c| mfi.next(c)).collect();
160        assert!(outputs.iter().all(|v| v.is_none()));
161    }
162
163    #[test]
164    fn mfi_all_rising_typical_price_saturates_at_100() {
165        let mut mfi = MFI::new(3);
166        let candles = vec![
167            candle(11.0, 9.0, 10.0, 100.0),
168            candle(12.0, 10.0, 11.0, 110.0),
169            candle(13.0, 11.0, 12.0, 120.0),
170            candle(14.0, 12.0, 13.0, 130.0),
171        ];
172        let outputs: Vec<_> = candles.iter().map(|c| mfi.next(c)).collect();
173        assert!(outputs[3].is_some());
174        assert!((outputs[3].unwrap() - 100.0).abs() < 1e-9);
175    }
176
177    #[test]
178    fn mfi_all_falling_typical_price_saturates_at_0() {
179        let mut mfi = MFI::new(3);
180        let candles = vec![
181            candle(14.0, 12.0, 13.0, 130.0),
182            candle(13.0, 11.0, 12.0, 120.0),
183            candle(12.0, 10.0, 11.0, 110.0),
184            candle(11.0, 9.0, 10.0, 100.0),
185        ];
186        let outputs: Vec<_> = candles.iter().map(|c| mfi.next(c)).collect();
187        assert!(outputs[3].is_some());
188        assert!(outputs[3].unwrap().abs() < 1e-9);
189    }
190
191    #[test]
192    fn mfi_flat_typical_price_excluded_from_both_sums() {
193        // Bar 1->2 has an unchanged typical price; it must contribute to
194        // neither the positive nor negative money flow sum.
195        let mut mfi = MFI::new(3);
196        let candles = vec![
197            candle(11.0, 9.0, 9.5, 100.0),
198            candle(12.0, 10.0, 10.5, 110.0),
199            candle(12.0, 10.0, 10.5, 120.0), // flat vs bar 2
200            candle(13.0, 11.0, 11.5, 130.0),
201        ];
202        let outputs: Vec<_> = candles.iter().map(|c| mfi.next(c)).collect();
203        // window covers diffs at bars 2,3,4: positive, flat(excluded), positive
204        // => neg_sum stays 0 => MFI saturates at 100 despite the flat bar.
205        assert!(outputs[3].is_some());
206        assert!((outputs[3].unwrap() - 100.0).abs() < 1e-9);
207    }
208
209    #[test]
210    fn mfi_reset_clears_state() {
211        let mut mfi = MFI::new(3);
212        let candles = vec![
213            candle(11.0, 9.0, 10.0, 100.0),
214            candle(12.0, 10.0, 11.0, 110.0),
215            candle(13.0, 11.0, 12.0, 120.0),
216            candle(14.0, 12.0, 13.0, 130.0),
217        ];
218        for c in &candles {
219            mfi.next(c);
220        }
221        mfi.reset();
222        assert!(mfi.next(&candles[0]).is_none());
223        assert_eq!(mfi.pos_sum, 0.0);
224        assert_eq!(mfi.neg_sum, 0.0);
225    }
226
227    #[test]
228    fn mfi_streaming_matches_batch() {
229        let candles = vec![
230            candle(11.0, 9.0, 10.0, 100.0),
231            candle(12.0, 10.0, 11.0, 110.0),
232            candle(13.0, 11.0, 12.0, 120.0),
233            candle(14.0, 12.0, 13.0, 130.0),
234            candle(12.0, 10.0, 11.0, 90.0),
235            candle(11.0, 9.0, 10.0, 95.0),
236        ];
237        let batch = MFI::new(3).calculate(&candles);
238        let mut streaming_out = Vec::new();
239        let mut mfi = MFI::new(3);
240        for c in &candles {
241            streaming_out.push(mfi.next(c));
242        }
243        assert_eq!(batch, streaming_out);
244    }
245}