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        Ok(Self {
62            period,
63            mfv_window: VecDeque::with_capacity(period),
64            vol_window: VecDeque::with_capacity(period),
65            mfv_sum: 0.0,
66            vol_sum: 0.0,
67        })
68    }
69
70    /// Configured period.
71    pub const fn period(&self) -> usize {
72        self.period
73    }
74}
75
76impl Indicator for ChaikinMoneyFlow {
77    type Input = Candle;
78    type Output = f64;
79
80    fn update(&mut self, candle: Candle) -> Option<f64> {
81        let range = candle.high - candle.low;
82        let mfv = if range == 0.0 {
83            // A zero-range bar carries no positional information.
84            0.0
85        } else {
86            let mfm = ((candle.close - candle.low) - (candle.high - candle.close)) / range;
87            mfm * candle.volume
88        };
89
90        if self.mfv_window.len() == self.period {
91            self.mfv_sum -= self.mfv_window.pop_front().expect("non-empty");
92            self.vol_sum -= self.vol_window.pop_front().expect("non-empty");
93        }
94        self.mfv_window.push_back(mfv);
95        self.vol_window.push_back(candle.volume);
96        self.mfv_sum += mfv;
97        self.vol_sum += candle.volume;
98
99        if self.mfv_window.len() < self.period {
100            return None;
101        }
102        if self.vol_sum == 0.0 {
103            // No volume traded across the whole window — no flow to report.
104            return Some(0.0);
105        }
106        Some(self.mfv_sum / self.vol_sum)
107    }
108
109    fn reset(&mut self) {
110        self.mfv_window.clear();
111        self.vol_window.clear();
112        self.mfv_sum = 0.0;
113        self.vol_sum = 0.0;
114    }
115
116    fn warmup_period(&self) -> usize {
117        self.period
118    }
119
120    fn is_ready(&self) -> bool {
121        self.mfv_window.len() == self.period
122    }
123
124    fn name(&self) -> &'static str {
125        "CMF"
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::traits::BatchExt;
133    use approx::assert_relative_eq;
134
135    fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
136        Candle::new(open, high, low, close, volume, ts).unwrap()
137    }
138
139    #[test]
140    fn reference_values() {
141        // CMF(2): bar 1 closes at the high -> MFM = +1, MFV = +100.
142        //         bar 2 closes mid-range -> MFM = 0, MFV = 0.
143        // CMF = (100 + 0) / (100 + 100) = 0.5.
144        let mut cmf = ChaikinMoneyFlow::new(2).unwrap();
145        let out = cmf.batch(&[
146            candle(8.0, 10.0, 8.0, 10.0, 100.0, 0),
147            candle(10.0, 12.0, 8.0, 10.0, 100.0, 1),
148        ]);
149        assert!(out[0].is_none());
150        assert_relative_eq!(out[1].unwrap(), 0.5, epsilon = 1e-12);
151    }
152
153    #[test]
154    fn stays_within_unit_range() {
155        let candles: Vec<Candle> = (0..120)
156            .map(|i| {
157                let mid = 100.0 + (i as f64 * 0.25).sin() * 10.0;
158                candle(
159                    mid,
160                    mid + 3.0,
161                    mid - 3.0,
162                    mid + (i as f64 * 0.5).cos() * 2.0,
163                    10.0 + (i % 7) as f64,
164                    i,
165                )
166            })
167            .collect();
168        let mut cmf = ChaikinMoneyFlow::new(20).unwrap();
169        for v in cmf.batch(&candles).into_iter().flatten() {
170            assert!((-1.0..=1.0).contains(&v), "CMF {v} outside [-1, 1]");
171        }
172    }
173
174    #[test]
175    fn closes_at_high_yield_cmf_one() {
176        // Every bar closes on its high -> MFM = +1 -> CMF saturates at +1.
177        let candles: Vec<Candle> = (0..30)
178            .map(|i| candle(9.0, 10.0, 8.0, 10.0, 50.0, i))
179            .collect();
180        let mut cmf = ChaikinMoneyFlow::new(14).unwrap();
181        for v in cmf.batch(&candles).into_iter().flatten() {
182            assert_relative_eq!(v, 1.0, epsilon = 1e-12);
183        }
184    }
185
186    #[test]
187    fn zero_volume_window_yields_zero() {
188        // A window with no traded volume divides 0/0 — defined as 0.0.
189        let candles: Vec<Candle> = (0..20)
190            .map(|i| candle(9.0, 10.0, 8.0, 10.0, 0.0, i))
191            .collect();
192        let mut cmf = ChaikinMoneyFlow::new(10).unwrap();
193        for v in cmf.batch(&candles).into_iter().flatten() {
194            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
195        }
196    }
197
198    #[test]
199    fn first_value_on_period_th_candle() {
200        let candles: Vec<Candle> = (0..10)
201            .map(|i| candle(9.0, 10.0, 8.0, 9.5, 50.0, i))
202            .collect();
203        let mut cmf = ChaikinMoneyFlow::new(5).unwrap();
204        let out = cmf.batch(&candles);
205        for (i, v) in out.iter().enumerate().take(4) {
206            assert!(v.is_none(), "index {i} must be None during warmup");
207        }
208        assert!(out[4].is_some(), "first CMF lands at index period - 1");
209        assert_eq!(cmf.warmup_period(), 5);
210    }
211
212    #[test]
213    fn rejects_zero_period() {
214        assert!(matches!(ChaikinMoneyFlow::new(0), Err(Error::PeriodZero)));
215    }
216
217    /// Cover the const accessor `period` (71-73) and the Indicator-impl
218    /// `name` body (124-126). `warmup_period` is covered elsewhere.
219    #[test]
220    fn accessors_and_metadata() {
221        let cmf = ChaikinMoneyFlow::new(20).unwrap();
222        assert_eq!(cmf.period(), 20);
223        assert_eq!(cmf.name(), "CMF");
224    }
225
226    /// Cover the `range == 0.0` defensive branch (line 84). All other
227    /// tests use H != L candles; feed all-flat candles (H == L) so the
228    /// MFV computation must take the zero-range fallback and emit MFV = 0.
229    #[test]
230    fn zero_range_candle_contributes_zero_mfv() {
231        let mut cmf = ChaikinMoneyFlow::new(3).unwrap();
232        let candles: Vec<Candle> = (0..5)
233            .map(|i| Candle::new(10.0, 10.0, 10.0, 10.0, 5.0, i).unwrap())
234            .collect();
235        let last = cmf
236            .batch(&candles)
237            .into_iter()
238            .flatten()
239            .last()
240            .expect("emits");
241        // Every bar contributed 0 to mfv_sum, so the ratio is 0.
242        assert_eq!(last, 0.0);
243    }
244
245    #[test]
246    fn reset_clears_state() {
247        let candles: Vec<Candle> = (0..20)
248            .map(|i| candle(9.0, 11.0, 8.0, 10.0, 50.0, i))
249            .collect();
250        let mut cmf = ChaikinMoneyFlow::new(10).unwrap();
251        cmf.batch(&candles);
252        assert!(cmf.is_ready());
253        cmf.reset();
254        assert!(!cmf.is_ready());
255        assert_eq!(cmf.update(candles[0]), None);
256    }
257
258    #[test]
259    fn batch_equals_streaming() {
260        let candles: Vec<Candle> = (0..80)
261            .map(|i| {
262                let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
263                candle(
264                    mid,
265                    mid + 2.0,
266                    mid - 2.0,
267                    mid + 0.5,
268                    10.0 + (i % 5) as f64,
269                    i,
270                )
271            })
272            .collect();
273        let mut a = ChaikinMoneyFlow::new(20).unwrap();
274        let mut b = ChaikinMoneyFlow::new(20).unwrap();
275        assert_eq!(
276            a.batch(&candles),
277            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
278        );
279    }
280}