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