Skip to main content

wickra_core/indicators/
twiggs_money_flow.rs

1//! Twiggs Money Flow (TMF) — Colin Twiggs' Wilder-smoothed money-flow oscillator.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::Indicator;
6
7/// Twiggs Money Flow — a refinement of Chaikin Money Flow that uses **true range**
8/// boundaries and **Wilder (exponential) smoothing** instead of a simple sum.
9///
10/// ```text
11/// TRH   = max(high, prev_close)          (true high)
12/// TRL   = min(low,  prev_close)          (true low)
13/// ad    = volume * (2*close − TRH − TRL) / (TRH − TRL)   (0 if TRH == TRL)
14/// TMF   = WilderEMA(ad, period) / WilderEMA(volume, period)
15/// ```
16///
17/// Colin Twiggs' money flow fixes two issues with [`Cmf`](crate::Cmf): it replaces
18/// the bar's raw high/low with the *true* high/low (folding in the prior close so
19/// gaps count), and it smooths the accumulated money flow and the volume with a
20/// Wilder exponential average rather than a flat `period`-sum, so the oscillator
21/// reacts faster and never jumps when a large bar drops out of a window. The
22/// output is bounded in roughly `[−1, +1]`: positive means buying pressure
23/// (closes biased toward the true high), negative means selling pressure.
24///
25/// The first candle seeds the reference close; the next `period` bars seed both
26/// Wilder averages, so the first value lands after `period + 1` inputs. A stretch
27/// of zero volume makes the denominator average `0`, in which case the oscillator
28/// reports `0` rather than `0 / 0`. Each `update` is O(1).
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{Candle, Indicator, TwiggsMoneyFlow};
34///
35/// let mut indicator = TwiggsMoneyFlow::new(21).unwrap();
36/// let mut last = None;
37/// for i in 0..60 {
38///     let base = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
39///     let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
40///     last = indicator.update(c);
41/// }
42/// assert!(last.is_some());
43/// ```
44#[derive(Debug, Clone)]
45pub struct TwiggsMoneyFlow {
46    period: usize,
47    prev_close: Option<f64>,
48    seed_ad: f64,
49    seed_vol: f64,
50    seed_count: usize,
51    ad_ema: Option<f64>,
52    vol_ema: Option<f64>,
53    last: Option<f64>,
54}
55
56impl TwiggsMoneyFlow {
57    /// Construct a new Twiggs Money Flow with the given smoothing `period`.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`Error::PeriodZero`] if `period == 0`.
62    pub fn new(period: usize) -> Result<Self> {
63        if period == 0 {
64            return Err(Error::PeriodZero);
65        }
66        Ok(Self {
67            period,
68            prev_close: None,
69            seed_ad: 0.0,
70            seed_vol: 0.0,
71            seed_count: 0,
72            ad_ema: None,
73            vol_ema: None,
74            last: None,
75        })
76    }
77
78    /// Configured smoothing period.
79    pub const fn period(&self) -> usize {
80        self.period
81    }
82
83    /// Current value if available.
84    pub const fn value(&self) -> Option<f64> {
85        self.last
86    }
87
88    fn ratio(ad_ema: f64, vol_ema: f64) -> f64 {
89        if vol_ema == 0.0 {
90            0.0
91        } else {
92            ad_ema / vol_ema
93        }
94    }
95}
96
97impl Indicator for TwiggsMoneyFlow {
98    type Input = Candle;
99    type Output = f64;
100
101    fn update(&mut self, candle: Candle) -> Option<f64> {
102        let Some(prev_close) = self.prev_close else {
103            self.prev_close = Some(candle.close);
104            return None;
105        };
106        let trh = candle.high.max(prev_close);
107        let trl = candle.low.min(prev_close);
108        let range = trh - trl;
109        let ad = if range > 0.0 {
110            candle.volume * (2.0 * candle.close - trh - trl) / range
111        } else {
112            0.0
113        };
114        self.prev_close = Some(candle.close);
115
116        if let (Some(ad_ema), Some(vol_ema)) = (self.ad_ema, self.vol_ema) {
117            let n = self.period as f64;
118            let new_ad = ad_ema + (ad - ad_ema) / n;
119            let new_vol = vol_ema + (candle.volume - vol_ema) / n;
120            self.ad_ema = Some(new_ad);
121            self.vol_ema = Some(new_vol);
122            let v = Self::ratio(new_ad, new_vol);
123            self.last = Some(v);
124            return Some(v);
125        }
126
127        self.seed_ad += ad;
128        self.seed_vol += candle.volume;
129        self.seed_count += 1;
130        if self.seed_count == self.period {
131            let n = self.period as f64;
132            let ad_ema = self.seed_ad / n;
133            let vol_ema = self.seed_vol / n;
134            self.ad_ema = Some(ad_ema);
135            self.vol_ema = Some(vol_ema);
136            let v = Self::ratio(ad_ema, vol_ema);
137            self.last = Some(v);
138            return Some(v);
139        }
140        None
141    }
142
143    fn reset(&mut self) {
144        self.prev_close = None;
145        self.seed_ad = 0.0;
146        self.seed_vol = 0.0;
147        self.seed_count = 0;
148        self.ad_ema = None;
149        self.vol_ema = None;
150        self.last = None;
151    }
152
153    fn warmup_period(&self) -> usize {
154        self.period + 1
155    }
156
157    fn is_ready(&self) -> bool {
158        self.last.is_some()
159    }
160
161    fn name(&self) -> &'static str {
162        "TwiggsMoneyFlow"
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::traits::BatchExt;
170    use approx::assert_relative_eq;
171
172    fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle {
173        Candle::new_unchecked(low, high, low, close, volume, 0)
174    }
175
176    #[test]
177    fn rejects_zero_period() {
178        assert!(matches!(TwiggsMoneyFlow::new(0), Err(Error::PeriodZero)));
179    }
180
181    #[test]
182    fn flat_bars_drive_tmf_to_zero() {
183        // A flat bar (high == low == close == prior close) gives a zero two-bar
184        // range, so the accumulation term falls back to 0.0 and TMF settles at
185        // zero. Exercises the `range == 0` guard.
186        let mut tmf = TwiggsMoneyFlow::new(2).unwrap();
187        let flat: Vec<Candle> = (0..6)
188            .map(|_| candle(100.0, 100.0, 100.0, 1_000.0))
189            .collect();
190        let last = tmf.batch(&flat).into_iter().flatten().last().unwrap();
191        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
192    }
193
194    #[test]
195    fn accessors_and_metadata() {
196        let tmf = TwiggsMoneyFlow::new(21).unwrap();
197        assert_eq!(tmf.period(), 21);
198        assert_eq!(tmf.warmup_period(), 22);
199        assert_eq!(tmf.name(), "TwiggsMoneyFlow");
200        assert!(!tmf.is_ready());
201        assert_eq!(tmf.value(), None);
202    }
203
204    #[test]
205    fn first_emission_at_warmup_period() {
206        let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
207        let candles: Vec<Candle> = (0..8)
208            .map(|i| {
209                let base = 100.0 + f64::from(i);
210                candle(base + 1.0, base - 1.0, base, 1_000.0)
211            })
212            .collect();
213        let out = tmf.batch(&candles);
214        // warmup_period == period + 1 == 4: first emission at index 3.
215        for o in out.iter().take(3) {
216            assert!(o.is_none());
217        }
218        assert!(out[3].is_some());
219    }
220
221    #[test]
222    fn closes_at_true_high_is_positive() {
223        // Every bar closes at its high -> strong buying pressure -> TMF -> +1.
224        let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
225        let candles: Vec<Candle> = (0..12)
226            .map(|i| {
227                let base = 100.0 + f64::from(i);
228                // open=low=base-1, high=close=base+1 -> closes at the top.
229                Candle::new_unchecked(base - 1.0, base + 1.0, base - 1.0, base + 1.0, 1_000.0, 0)
230            })
231            .collect();
232        let last = tmf.batch(&candles).into_iter().flatten().last().unwrap();
233        assert!(
234            last > 0.9,
235            "closing at the high should drive TMF near +1, got {last}"
236        );
237    }
238
239    #[test]
240    fn closes_at_true_low_is_negative() {
241        let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
242        let candles: Vec<Candle> = (0..12)
243            .map(|i| {
244                let base = 100.0 - f64::from(i);
245                // closes at the low.
246                Candle::new_unchecked(base + 1.0, base + 1.0, base - 1.0, base - 1.0, 1_000.0, 0)
247            })
248            .collect();
249        let last = tmf.batch(&candles).into_iter().flatten().last().unwrap();
250        assert!(
251            last < -0.5,
252            "closing at the low should drive TMF negative, got {last}"
253        );
254    }
255
256    #[test]
257    fn zero_volume_yields_zero() {
258        let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
259        let candles: Vec<Candle> = (0..10)
260            .map(|i| {
261                let base = 100.0 + f64::from(i);
262                candle(base + 1.0, base - 1.0, base, 0.0)
263            })
264            .collect();
265        for v in tmf.batch(&candles).into_iter().flatten() {
266            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
267        }
268    }
269
270    #[test]
271    fn output_in_range() {
272        let mut tmf = TwiggsMoneyFlow::new(21).unwrap();
273        let candles: Vec<Candle> = (0..200)
274            .map(|i| {
275                let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
276                candle(base + 2.0, base - 2.0, base + 0.5, 1_000.0)
277            })
278            .collect();
279        for v in tmf.batch(&candles).into_iter().flatten() {
280            assert!((-1.0..=1.0).contains(&v), "TMF out of range: {v}");
281        }
282    }
283
284    #[test]
285    fn reset_clears_state() {
286        let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
287        let candles: Vec<Candle> = (0..12)
288            .map(|i| {
289                let base = 100.0 + f64::from(i);
290                candle(base + 1.0, base - 1.0, base, 1_000.0)
291            })
292            .collect();
293        tmf.batch(&candles);
294        assert!(tmf.is_ready());
295        tmf.reset();
296        assert!(!tmf.is_ready());
297        assert_eq!(tmf.value(), None);
298        assert_eq!(tmf.update(candle(101.0, 99.0, 100.0, 1_000.0)), None);
299    }
300
301    #[test]
302    fn batch_equals_streaming() {
303        let candles: Vec<Candle> = (0..120)
304            .map(|i| {
305                let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
306                candle(base + 2.0, base - 1.5, base + 0.5, 1_000.0 + f64::from(i))
307            })
308            .collect();
309        let batch = TwiggsMoneyFlow::new(21).unwrap().batch(&candles);
310        let mut b = TwiggsMoneyFlow::new(21).unwrap();
311        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
312        assert_eq!(batch, streamed);
313    }
314}