Skip to main content

indicators/volume/
chaikin_money_flow.rs

1//! Chaikin Money Flow (CMF).
2//!
3//! Python source: `indicators/other/chaikin_money_flow.py :: class ChaikinMoneyFlow`
4//!
5//! # Python algorithm (to port)
6//! ```python
7//! high_low_range       = (df["High"] - df["Low"]).replace(0, np.nan)
8//! money_flow_mult      = ((df["Close"] - df["Low"]) - (df["High"] - df["Close"])) / high_low_range
9//! money_flow_volume    = money_flow_mult * df["Volume"]
10//! sum_mfv              = money_flow_volume.rolling(window=self.period).sum()
11//! sum_vol              = df["Volume"].rolling(window=self.period).sum().replace(0, np.nan)
12//! cmf                  = sum_mfv / sum_vol
13//! ```
14//!
15//! Values above +0.20 → strong buying; below -0.20 → strong selling.
16//! Oscillates between -1 and +1.
17//!
18//! Output column: `"CMF_{period}"`.
19
20use std::collections::HashMap;
21
22use crate::error::IndicatorError;
23use crate::indicator::{Indicator, IndicatorOutput};
24use crate::registry::param_usize;
25use crate::types::Candle;
26
27#[derive(Debug, Clone)]
28pub struct CmfParams {
29    /// Rolling period.  Python default: 20.
30    pub period: usize,
31}
32impl Default for CmfParams {
33    fn default() -> Self {
34        Self { period: 20 }
35    }
36}
37
38#[derive(Debug, Clone)]
39pub struct ChaikinMoneyFlow {
40    pub params: CmfParams,
41}
42
43impl ChaikinMoneyFlow {
44    pub fn new(params: CmfParams) -> Self {
45        Self { params }
46    }
47    pub fn with_period(period: usize) -> Self {
48        Self::new(CmfParams { period })
49    }
50    fn output_key(&self) -> String {
51        format!("CMF_{}", self.params.period)
52    }
53}
54
55impl Indicator for ChaikinMoneyFlow {
56    fn name(&self) -> &'static str {
57        "ChaikinMoneyFlow"
58    }
59    fn required_len(&self) -> usize {
60        self.params.period
61    }
62    fn required_columns(&self) -> &[&'static str] {
63        &["high", "low", "close", "volume"]
64    }
65
66    /// Ports the rolling-window CMF calculation.
67    ///
68    /// When `high == low` the money-flow multiplier is set to `0.0` rather
69    /// than `NaN`.  This is equivalent to Python's `.replace(0, np.nan)`
70    /// approach because pandas `rolling().sum()` skips `NaN` values by
71    /// default, so a zero-range bar contributes `0` to the rolling sum in
72    /// both implementations.
73    fn calculate(&self, candles: &[Candle]) -> Result<IndicatorOutput, IndicatorError> {
74        self.check_len(candles)?;
75
76        let n = candles.len();
77        let p = self.params.period;
78
79        // Money flow multiplier and volume per bar.
80        let mfv: Vec<f64> = candles
81            .iter()
82            .map(|c| {
83                let range = c.high - c.low;
84                let mfm = if range == 0.0 {
85                    0.0
86                } else {
87                    ((c.close - c.low) - (c.high - c.close)) / range
88                };
89                mfm * c.volume
90            })
91            .collect();
92        let vol: Vec<f64> = candles.iter().map(|c| c.volume).collect();
93
94        let mut values = vec![f64::NAN; n];
95        for i in (p - 1)..n {
96            let sum_mfv: f64 = mfv[(i + 1 - p)..=i].iter().sum();
97            let sum_vol: f64 = vol[(i + 1 - p)..=i].iter().sum();
98            values[i] = if sum_vol == 0.0 {
99                f64::NAN
100            } else {
101                sum_mfv / sum_vol
102            };
103        }
104
105        Ok(IndicatorOutput::from_pairs([(self.output_key(), values)]))
106    }
107}
108
109pub fn factory<S: ::std::hash::BuildHasher>(
110    params: &HashMap<String, String, S>,
111) -> Result<Box<dyn Indicator>, IndicatorError> {
112    Ok(Box::new(ChaikinMoneyFlow::new(CmfParams {
113        period: param_usize(params, "period", 20)?,
114    })))
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn candles(n: usize) -> Vec<Candle> {
122        (0..n)
123            .map(|i| Candle {
124                time: i64::try_from(i).expect("time index fits i64"),
125                open: 10.0,
126                high: 12.0,
127                low: 8.0,
128                close: 11.0,
129                volume: 100.0,
130            })
131            .collect()
132    }
133
134    #[test]
135    fn cmf_output_column() {
136        let out = ChaikinMoneyFlow::with_period(20)
137            .calculate(&candles(25))
138            .unwrap();
139        assert!(out.get("CMF_20").is_some());
140    }
141
142    #[test]
143    fn cmf_range_neg1_to_pos1() {
144        let out = ChaikinMoneyFlow::with_period(5)
145            .calculate(&candles(10))
146            .unwrap();
147        for &v in out.get("CMF_5").unwrap() {
148            if !v.is_nan() {
149                assert!((-1.0..=1.0).contains(&v), "out of range: {v}");
150            }
151        }
152    }
153
154    #[test]
155    fn factory_creates_cmf() {
156        assert_eq!(factory(&HashMap::new()).unwrap().name(), "ChaikinMoneyFlow");
157    }
158}