Skip to main content

wickra_core/indicators/
cmf.rs

1//! Chaikin Money Flow (CMF).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Chaikin Money Flow — Marc Chaikin's `period`-window money-flow oscillator.
10///
11/// Each bar produces a *money-flow volume*: the bar's volume weighted by where
12/// the close fell within its range (the same money-flow multiplier the
13/// [`Adl`](crate::Adl) uses). CMF is the ratio of summed money-flow volume to
14/// summed volume over the lookback window:
15///
16/// ```text
17/// MFM_t = ((close − low) − (high − close)) / (high − low)   (−1..+1)
18/// MFV_t = MFM_t · volume_t
19/// CMF_t = Σ(MFV, period) / Σ(volume, period)
20/// ```
21///
22/// The result lives in `[−1, +1]`: sustained closes near the high push CMF
23/// toward `+1` (accumulation), near the low toward `−1` (distribution). A bar
24/// with `high == low` carries no positional information and contributes a
25/// money-flow volume of `0`; a window whose total volume is zero yields `0.0`
26/// by convention.
27///
28/// # Example
29///
30/// ```
31/// use wickra_core::{Candle, Indicator, ChaikinMoneyFlow};
32///
33/// let mut indicator = ChaikinMoneyFlow::new(20).unwrap();
34/// let mut last = None;
35/// for i in 0..80 {
36///     let base = 100.0 + f64::from(i);
37///     let candle =
38///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
39///     last = indicator.update(candle);
40/// }
41/// assert!(last.is_some());
42/// ```
43#[derive(Debug, Clone)]
44pub struct ChaikinMoneyFlow {
45    period: usize,
46    mfv_window: VecDeque<f64>,
47    vol_window: VecDeque<f64>,
48    mfv_sum: f64,
49    vol_sum: f64,
50}
51
52impl ChaikinMoneyFlow {
53    /// Construct a new Chaikin Money Flow over `period` bars.
54    ///
55    /// # Errors
56    /// Returns [`Error::PeriodZero`] if `period == 0`.
57    pub fn new(period: usize) -> Result<Self> {
58        if period == 0 {
59            return Err(Error::PeriodZero);
60        }
61        if period > crate::error::MAX_PERIOD {
62            return Err(Error::InvalidPeriod {
63                message: crate::error::PERIOD_ABOVE_MAX,
64            });
65        }
66        Ok(Self {
67            period,
68            mfv_window: VecDeque::with_capacity(period),
69            vol_window: VecDeque::with_capacity(period),
70            mfv_sum: 0.0,
71            vol_sum: 0.0,
72        })
73    }
74
75    /// Configured period.
76    pub const fn period(&self) -> usize {
77        self.period
78    }
79}
80
81impl Indicator for ChaikinMoneyFlow {
82    type Input = Candle;
83    type Output = f64;
84
85    #[inline]
86    fn update(&mut self, candle: Candle) -> Option<f64> {
87        let range = candle.high - candle.low;
88        let mfv = if range == 0.0 {
89            // A zero-range bar carries no positional information.
90            0.0
91        } else {
92            let mfm = ((candle.close - candle.low) - (candle.high - candle.close)) / range;
93            mfm * candle.volume
94        };
95
96        if self.mfv_window.len() == self.period {
97            self.mfv_sum -= self.mfv_window.pop_front().expect("non-empty");
98            self.vol_sum -= self.vol_window.pop_front().expect("non-empty");
99        }
100        self.mfv_window.push_back(mfv);
101        self.vol_window.push_back(candle.volume);
102        self.mfv_sum += mfv;
103        self.vol_sum += candle.volume;
104
105        if self.mfv_window.len() < self.period {
106            return None;
107        }
108        if self.vol_sum == 0.0 {
109            // No volume traded across the whole window — no flow to report.
110            return Some(0.0);
111        }
112        Some(self.mfv_sum / self.vol_sum)
113    }
114
115    fn reset(&mut self) {
116        self.mfv_window.clear();
117        self.vol_window.clear();
118        self.mfv_sum = 0.0;
119        self.vol_sum = 0.0;
120    }
121
122    #[inline]
123    fn warmup_period(&self) -> usize {
124        self.period
125    }
126
127    #[inline]
128    fn is_ready(&self) -> bool {
129        self.mfv_window.len() == self.period
130    }
131
132    #[inline]
133    fn name(&self) -> &'static str {
134        "CMF"
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::traits::BatchExt;
142    use approx::assert_relative_eq;
143
144    fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
145        Candle::new(open, high, low, close, volume, ts).unwrap()
146    }
147
148    #[test]
149    fn reference_values() {
150        // CMF(2): bar 1 closes at the high -> MFM = +1, MFV = +100.
151        //         bar 2 closes mid-range -> MFM = 0, MFV = 0.
152        // CMF = (100 + 0) / (100 + 100) = 0.5.
153        let mut cmf = ChaikinMoneyFlow::new(2).unwrap();
154        let out = cmf.batch(&[
155            candle(8.0, 10.0, 8.0, 10.0, 100.0, 0),
156            candle(10.0, 12.0, 8.0, 10.0, 100.0, 1),
157        ]);
158        assert!(out[0].is_none());
159        assert_relative_eq!(out[1].unwrap(), 0.5, epsilon = 1e-12);
160    }
161
162    #[test]
163    fn stays_within_unit_range() {
164        let candles: Vec<Candle> = (0..120)
165            .map(|i| {
166                let mid = 100.0 + (i as f64 * 0.25).sin() * 10.0;
167                candle(
168                    mid,
169                    mid + 3.0,
170                    mid - 3.0,
171                    mid + (i as f64 * 0.5).cos() * 2.0,
172                    10.0 + (i % 7) as f64,
173                    i,
174                )
175            })
176            .collect();
177        let mut cmf = ChaikinMoneyFlow::new(20).unwrap();
178        for v in cmf.batch(&candles).into_iter().flatten() {
179            assert!((-1.0..=1.0).contains(&v), "CMF {v} outside [-1, 1]");
180        }
181    }
182
183    #[test]
184    fn closes_at_high_yield_cmf_one() {
185        // Every bar closes on its high -> MFM = +1 -> CMF saturates at +1.
186        let candles: Vec<Candle> = (0..30)
187            .map(|i| candle(9.0, 10.0, 8.0, 10.0, 50.0, i))
188            .collect();
189        let mut cmf = ChaikinMoneyFlow::new(14).unwrap();
190        for v in cmf.batch(&candles).into_iter().flatten() {
191            assert_relative_eq!(v, 1.0, epsilon = 1e-12);
192        }
193    }
194
195    #[test]
196    fn zero_volume_window_yields_zero() {
197        // A window with no traded volume divides 0/0 — defined as 0.0.
198        let candles: Vec<Candle> = (0..20)
199            .map(|i| candle(9.0, 10.0, 8.0, 10.0, 0.0, i))
200            .collect();
201        let mut cmf = ChaikinMoneyFlow::new(10).unwrap();
202        for v in cmf.batch(&candles).into_iter().flatten() {
203            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
204        }
205    }
206
207    #[test]
208    fn first_value_on_period_th_candle() {
209        let candles: Vec<Candle> = (0..10)
210            .map(|i| candle(9.0, 10.0, 8.0, 9.5, 50.0, i))
211            .collect();
212        let mut cmf = ChaikinMoneyFlow::new(5).unwrap();
213        let out = cmf.batch(&candles);
214        for (i, v) in out.iter().enumerate().take(4) {
215            assert!(v.is_none(), "index {i} must be None during warmup");
216        }
217        assert!(out[4].is_some(), "first CMF lands at index period - 1");
218        assert_eq!(cmf.warmup_period(), 5);
219    }
220
221    #[test]
222    fn rejects_zero_period() {
223        assert!(matches!(ChaikinMoneyFlow::new(0), Err(Error::PeriodZero)));
224    }
225
226    /// Cover the const accessor `period` (71-73) and the Indicator-impl
227    /// `name` body (124-126). `warmup_period` is covered elsewhere.
228    #[test]
229    fn accessors_and_metadata() {
230        let cmf = ChaikinMoneyFlow::new(20).unwrap();
231        assert_eq!(cmf.period(), 20);
232        assert_eq!(cmf.name(), "CMF");
233    }
234
235    /// Cover the `range == 0.0` defensive branch (line 84). All other
236    /// tests use H != L candles; feed all-flat candles (H == L) so the
237    /// MFV computation must take the zero-range fallback and emit MFV = 0.
238    #[test]
239    fn zero_range_candle_contributes_zero_mfv() {
240        let mut cmf = ChaikinMoneyFlow::new(3).unwrap();
241        let candles: Vec<Candle> = (0..5)
242            .map(|i| Candle::new(10.0, 10.0, 10.0, 10.0, 5.0, i).unwrap())
243            .collect();
244        let last = cmf
245            .batch(&candles)
246            .into_iter()
247            .flatten()
248            .last()
249            .expect("emits");
250        // Every bar contributed 0 to mfv_sum, so the ratio is 0.
251        assert_eq!(last, 0.0);
252    }
253
254    #[test]
255    fn reset_clears_state() {
256        let candles: Vec<Candle> = (0..20)
257            .map(|i| candle(9.0, 11.0, 8.0, 10.0, 50.0, i))
258            .collect();
259        let mut cmf = ChaikinMoneyFlow::new(10).unwrap();
260        cmf.batch(&candles);
261        assert!(cmf.is_ready());
262        cmf.reset();
263        assert!(!cmf.is_ready());
264        assert_eq!(cmf.update(candles[0]), None);
265    }
266
267    #[test]
268    fn batch_equals_streaming() {
269        let candles: Vec<Candle> = (0..80)
270            .map(|i| {
271                let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
272                candle(
273                    mid,
274                    mid + 2.0,
275                    mid - 2.0,
276                    mid + 0.5,
277                    10.0 + (i % 5) as f64,
278                    i,
279                )
280            })
281            .collect();
282        let mut a = ChaikinMoneyFlow::new(20).unwrap();
283        let mut b = ChaikinMoneyFlow::new(20).unwrap();
284        assert_eq!(
285            a.batch(&candles),
286            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
287        );
288    }
289}