Skip to main content

wickra_core/indicators/
mfi.rs

1//! Money Flow Index (MFI).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::RollingSum;
7use crate::ohlcv::Candle;
8use crate::traits::Indicator;
9
10/// Money Flow Index: a volume-weighted version of RSI.
11///
12/// `MFI = 100 - 100 / (1 + positive_money_flow / negative_money_flow)` where
13/// money flow is `typical_price * volume`, classified positive when TP increases
14/// and negative when it decreases.
15///
16/// # Example
17///
18/// ```
19/// use wickra_core::{Candle, Indicator, Mfi};
20///
21/// let mut indicator = Mfi::new(5).unwrap();
22/// let mut last = None;
23/// for i in 0..80 {
24///     let base = 100.0 + f64::from(i);
25///     let candle =
26///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
27///     last = indicator.update(candle);
28/// }
29/// assert!(last.is_some());
30/// ```
31#[derive(Debug, Clone)]
32pub struct Mfi {
33    period: usize,
34    prev_tp: Option<f64>,
35    pos_window: VecDeque<f64>,
36    neg_window: VecDeque<f64>,
37    pos_sum: RollingSum,
38    neg_sum: RollingSum,
39}
40
41impl Mfi {
42    /// # Errors
43    /// Returns [`Error::PeriodZero`] if `period == 0`.
44    pub fn new(period: usize) -> Result<Self> {
45        if period == 0 {
46            return Err(Error::PeriodZero);
47        }
48        if period > crate::error::MAX_PERIOD {
49            return Err(Error::InvalidPeriod {
50                message: crate::error::PERIOD_ABOVE_MAX,
51            });
52        }
53        Ok(Self {
54            period,
55            prev_tp: None,
56            pos_window: VecDeque::with_capacity(period),
57            neg_window: VecDeque::with_capacity(period),
58            pos_sum: RollingSum::new(),
59            neg_sum: RollingSum::new(),
60        })
61    }
62
63    /// Configured period.
64    pub const fn period(&self) -> usize {
65        self.period
66    }
67}
68
69impl Indicator for Mfi {
70    type Input = Candle;
71    type Output = f64;
72
73    fn update(&mut self, candle: Candle) -> Option<f64> {
74        let tp = candle.typical_price();
75
76        // The very first candle only establishes the previous typical price.
77        // It carries no money-flow direction, so it is not pushed into the
78        // window. This matches TA-Lib / pandas-ta, which need `period + 1`
79        // candles before the first MFI value.
80        let Some(prev) = self.prev_tp else {
81            self.prev_tp = Some(tp);
82            return None;
83        };
84
85        let mf = tp * candle.volume;
86        let (pos_flow, neg_flow) = if tp > prev {
87            (mf, 0.0)
88        } else if tp < prev {
89            (0.0, mf)
90        } else {
91            (0.0, 0.0)
92        };
93
94        if self.pos_window.len() == self.period {
95            let old_pos = self.pos_window.pop_front().expect("non-empty");
96            let old_neg = self.neg_window.pop_front().expect("non-empty");
97            self.pos_sum.evict(old_pos);
98            self.neg_sum.evict(old_neg);
99        }
100        self.pos_window.push_back(pos_flow);
101        self.neg_window.push_back(neg_flow);
102        self.pos_sum.push(pos_flow);
103        self.neg_sum.push(neg_flow);
104        if self.pos_sum.needs_reseed(self.period) {
105            self.pos_sum.reseed(self.pos_window.iter().copied());
106            self.neg_sum.reseed(self.neg_window.iter().copied());
107        }
108
109        self.prev_tp = Some(tp);
110
111        if self.pos_window.len() < self.period {
112            return None;
113        }
114        // A fully flat window (every typical price equal) has zero flow on
115        // both sides; by convention MFI is then 50.
116        let (pos_sum, neg_sum) = (self.pos_sum.value(), self.neg_sum.value());
117        if pos_sum == 0.0 && neg_sum == 0.0 {
118            return Some(50.0);
119        }
120        if neg_sum == 0.0 {
121            return Some(100.0);
122        }
123        let mr = pos_sum / neg_sum;
124        Some(100.0 - 100.0 / (1.0 + mr))
125    }
126
127    fn reset(&mut self) {
128        self.prev_tp = None;
129        self.pos_window.clear();
130        self.neg_window.clear();
131        self.pos_sum.reset();
132        self.neg_sum.reset();
133    }
134
135    #[inline]
136    fn warmup_period(&self) -> usize {
137        // One seed candle establishes the first previous typical price, then
138        // `period` flow comparisons fill the window.
139        self.period + 1
140    }
141
142    #[inline]
143    fn is_ready(&self) -> bool {
144        self.pos_window.len() == self.period
145    }
146
147    #[inline]
148    fn name(&self) -> &'static str {
149        "MFI"
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::traits::BatchExt;
157    use approx::assert_relative_eq;
158
159    fn c(price: f64, volume: f64) -> Candle {
160        Candle::new(price, price, price, price, volume, 0).unwrap()
161    }
162
163    #[test]
164    fn pure_uptrend_yields_high_mfi() {
165        let candles: Vec<Candle> = (1..30).map(|i| c(f64::from(i), 100.0)).collect();
166        let mut mfi = Mfi::new(14).unwrap();
167        let last = mfi.batch(&candles).into_iter().flatten().last().unwrap();
168        assert_relative_eq!(last, 100.0, epsilon = 1e-9);
169    }
170
171    #[test]
172    fn pure_downtrend_yields_low_mfi() {
173        let candles: Vec<Candle> = (1..30).rev().map(|i| c(f64::from(i), 100.0)).collect();
174        let mut mfi = Mfi::new(14).unwrap();
175        let last = mfi.batch(&candles).into_iter().flatten().last().unwrap();
176        assert_relative_eq!(last, 0.0, epsilon = 1e-9);
177    }
178
179    #[test]
180    fn batch_equals_streaming() {
181        let candles: Vec<Candle> = (0..40).map(|i| c(f64::from(i) + 10.0, 50.0)).collect();
182        let mut a = Mfi::new(14).unwrap();
183        let mut b = Mfi::new(14).unwrap();
184        assert_eq!(
185            a.batch(&candles),
186            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
187        );
188    }
189
190    #[test]
191    fn reset_clears_state() {
192        let candles: Vec<Candle> = (1..30).map(|i| c(f64::from(i), 100.0)).collect();
193        let mut mfi = Mfi::new(14).unwrap();
194        mfi.batch(&candles);
195        assert!(mfi.is_ready());
196        mfi.reset();
197        assert!(!mfi.is_ready());
198    }
199
200    /// Cover the const accessor `period` (58-60) and the Indicator-impl
201    /// `name` body (132-134). `warmup_period` is already covered elsewhere.
202    #[test]
203    fn accessors_and_metadata() {
204        let mfi = Mfi::new(14).unwrap();
205        assert_eq!(mfi.period(), 14);
206        assert_eq!(mfi.name(), "MFI");
207    }
208
209    /// Cover the `tp == prev` arm (line 85) — when typical price equals
210    /// the previous typical price, both flows are 0 — and the all-zero-
211    /// flow fallback `Some(50.0)` (line 105). Existing tests use varying
212    /// candles so the flat-TP arm and the zero-flow fallback never fired.
213    #[test]
214    fn flat_typical_prices_default_to_50() {
215        let mut mfi = Mfi::new(3).unwrap();
216        let candles: Vec<Candle> = (0..6)
217            .map(|i| Candle::new(10.0, 10.0, 10.0, 10.0, 1.0, i).unwrap())
218            .collect();
219        let last = mfi
220            .batch(&candles)
221            .into_iter()
222            .flatten()
223            .last()
224            .expect("emits");
225        assert_eq!(last, 50.0);
226    }
227
228    #[test]
229    fn rejects_zero_period() {
230        assert!(Mfi::new(0).is_err());
231    }
232
233    #[test]
234    fn first_value_emitted_on_period_plus_one_candle() {
235        // The seed candle plus `period` flow comparisons -> first MFI on the
236        // (period + 1)-th candle (index `period`).
237        let candles: Vec<Candle> = (1..=20).map(|i| c(f64::from(i), 100.0)).collect();
238        let mut mfi = Mfi::new(5).unwrap();
239        let out = mfi.batch(&candles);
240        for (i, v) in out.iter().enumerate().take(5) {
241            assert!(v.is_none(), "candle index {i} must be None during warmup");
242        }
243        assert!(
244            out[5].is_some(),
245            "first MFI value lands at index period (5)"
246        );
247        assert_eq!(mfi.warmup_period(), 6);
248    }
249
250    #[test]
251    fn known_value_period_2() {
252        // Three candles, MFI(2). Candle 1 (tp=10) only seeds the previous TP.
253        // Candle 2 (tp=12 > 10): positive money flow 12 * 100 = 1200.
254        // Candle 3 (tp=11 < 12): negative money flow 11 * 100 = 1100.
255        // money ratio = 1200 / 1100; MFI = 100 - 100 / (1 + 1200/1100) = 1200/23.
256        let candles = vec![c(10.0, 100.0), c(12.0, 100.0), c(11.0, 100.0)];
257        let mut mfi = Mfi::new(2).unwrap();
258        let out = mfi.batch(&candles);
259        assert!(out[0].is_none());
260        assert!(out[1].is_none());
261        assert_relative_eq!(out[2].unwrap(), 1200.0 / 23.0, epsilon = 1e-9);
262    }
263}