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 [`ChaikinMoneyFlow`](crate::ChaikinMoneyFlow): 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        if period > crate::error::MAX_PERIOD {
67            return Err(Error::InvalidPeriod {
68                message: crate::error::PERIOD_ABOVE_MAX,
69            });
70        }
71        Ok(Self {
72            period,
73            prev_close: None,
74            seed_ad: 0.0,
75            seed_vol: 0.0,
76            seed_count: 0,
77            ad_ema: None,
78            vol_ema: None,
79            last: None,
80        })
81    }
82
83    /// Configured smoothing period.
84    pub const fn period(&self) -> usize {
85        self.period
86    }
87
88    /// Current value if available.
89    pub const fn value(&self) -> Option<f64> {
90        self.last
91    }
92
93    fn ratio(ad_ema: f64, vol_ema: f64) -> f64 {
94        if vol_ema == 0.0 {
95            0.0
96        } else {
97            ad_ema / vol_ema
98        }
99    }
100}
101
102impl Indicator for TwiggsMoneyFlow {
103    type Input = Candle;
104    type Output = f64;
105
106    fn update(&mut self, candle: Candle) -> Option<f64> {
107        let Some(prev_close) = self.prev_close else {
108            self.prev_close = Some(candle.close);
109            return None;
110        };
111        let trh = candle.high.max(prev_close);
112        let trl = candle.low.min(prev_close);
113        let range = trh - trl;
114        let ad = if range > 0.0 {
115            candle.volume * (2.0 * candle.close - trh - trl) / range
116        } else {
117            0.0
118        };
119        self.prev_close = Some(candle.close);
120
121        if let (Some(ad_ema), Some(vol_ema)) = (self.ad_ema, self.vol_ema) {
122            let n = self.period as f64;
123            let new_ad = ad_ema + (ad - ad_ema) / n;
124            let new_vol = vol_ema + (candle.volume - vol_ema) / n;
125            self.ad_ema = Some(new_ad);
126            self.vol_ema = Some(new_vol);
127            let v = Self::ratio(new_ad, new_vol);
128            self.last = Some(v);
129            return Some(v);
130        }
131
132        self.seed_ad += ad;
133        self.seed_vol += candle.volume;
134        self.seed_count += 1;
135        if self.seed_count == self.period {
136            let n = self.period as f64;
137            let ad_ema = self.seed_ad / n;
138            let vol_ema = self.seed_vol / n;
139            self.ad_ema = Some(ad_ema);
140            self.vol_ema = Some(vol_ema);
141            let v = Self::ratio(ad_ema, vol_ema);
142            self.last = Some(v);
143            return Some(v);
144        }
145        None
146    }
147
148    fn reset(&mut self) {
149        self.prev_close = None;
150        self.seed_ad = 0.0;
151        self.seed_vol = 0.0;
152        self.seed_count = 0;
153        self.ad_ema = None;
154        self.vol_ema = None;
155        self.last = None;
156    }
157
158    #[inline]
159    fn warmup_period(&self) -> usize {
160        self.period + 1
161    }
162
163    #[inline]
164    fn is_ready(&self) -> bool {
165        self.last.is_some()
166    }
167
168    #[inline]
169    fn name(&self) -> &'static str {
170        "TwiggsMoneyFlow"
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::traits::BatchExt;
178    use approx::assert_relative_eq;
179
180    fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle {
181        Candle::new_unchecked(low, high, low, close, volume, 0)
182    }
183
184    #[test]
185    fn rejects_zero_period() {
186        assert!(matches!(TwiggsMoneyFlow::new(0), Err(Error::PeriodZero)));
187    }
188
189    #[test]
190    fn flat_bars_drive_tmf_to_zero() {
191        // A flat bar (high == low == close == prior close) gives a zero two-bar
192        // range, so the accumulation term falls back to 0.0 and TMF settles at
193        // zero. Exercises the `range == 0` guard.
194        let mut tmf = TwiggsMoneyFlow::new(2).unwrap();
195        let flat: Vec<Candle> = (0..6)
196            .map(|_| candle(100.0, 100.0, 100.0, 1_000.0))
197            .collect();
198        let last = tmf.batch(&flat).into_iter().flatten().last().unwrap();
199        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
200    }
201
202    #[test]
203    fn accessors_and_metadata() {
204        let tmf = TwiggsMoneyFlow::new(21).unwrap();
205        assert_eq!(tmf.period(), 21);
206        assert_eq!(tmf.warmup_period(), 22);
207        assert_eq!(tmf.name(), "TwiggsMoneyFlow");
208        assert!(!tmf.is_ready());
209        assert_eq!(tmf.value(), None);
210    }
211
212    #[test]
213    fn first_emission_at_warmup_period() {
214        let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
215        let candles: Vec<Candle> = (0..8)
216            .map(|i| {
217                let base = 100.0 + f64::from(i);
218                candle(base + 1.0, base - 1.0, base, 1_000.0)
219            })
220            .collect();
221        let out = tmf.batch(&candles);
222        // warmup_period == period + 1 == 4: first emission at index 3.
223        for o in out.iter().take(3) {
224            assert!(o.is_none());
225        }
226        assert!(out[3].is_some());
227    }
228
229    #[test]
230    fn closes_at_true_high_is_positive() {
231        // Every bar closes at its high -> strong buying pressure -> TMF -> +1.
232        let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
233        let candles: Vec<Candle> = (0..12)
234            .map(|i| {
235                let base = 100.0 + f64::from(i);
236                // open=low=base-1, high=close=base+1 -> closes at the top.
237                Candle::new_unchecked(base - 1.0, base + 1.0, base - 1.0, base + 1.0, 1_000.0, 0)
238            })
239            .collect();
240        let last = tmf.batch(&candles).into_iter().flatten().last().unwrap();
241        assert!(
242            last > 0.9,
243            "closing at the high should drive TMF near +1, got {last}"
244        );
245    }
246
247    #[test]
248    fn closes_at_true_low_is_negative() {
249        let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
250        let candles: Vec<Candle> = (0..12)
251            .map(|i| {
252                let base = 100.0 - f64::from(i);
253                // closes at the low.
254                Candle::new_unchecked(base + 1.0, base + 1.0, base - 1.0, base - 1.0, 1_000.0, 0)
255            })
256            .collect();
257        let last = tmf.batch(&candles).into_iter().flatten().last().unwrap();
258        assert!(
259            last < -0.5,
260            "closing at the low should drive TMF negative, got {last}"
261        );
262    }
263
264    #[test]
265    fn zero_volume_yields_zero() {
266        let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
267        let candles: Vec<Candle> = (0..10)
268            .map(|i| {
269                let base = 100.0 + f64::from(i);
270                candle(base + 1.0, base - 1.0, base, 0.0)
271            })
272            .collect();
273        for v in tmf.batch(&candles).into_iter().flatten() {
274            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
275        }
276    }
277
278    #[test]
279    fn output_in_range() {
280        let mut tmf = TwiggsMoneyFlow::new(21).unwrap();
281        let candles: Vec<Candle> = (0..200)
282            .map(|i| {
283                let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
284                candle(base + 2.0, base - 2.0, base + 0.5, 1_000.0)
285            })
286            .collect();
287        for v in tmf.batch(&candles).into_iter().flatten() {
288            assert!((-1.0..=1.0).contains(&v), "TMF out of range: {v}");
289        }
290    }
291
292    #[test]
293    fn reset_clears_state() {
294        let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
295        let candles: Vec<Candle> = (0..12)
296            .map(|i| {
297                let base = 100.0 + f64::from(i);
298                candle(base + 1.0, base - 1.0, base, 1_000.0)
299            })
300            .collect();
301        tmf.batch(&candles);
302        assert!(tmf.is_ready());
303        tmf.reset();
304        assert!(!tmf.is_ready());
305        assert_eq!(tmf.value(), None);
306        assert_eq!(tmf.update(candle(101.0, 99.0, 100.0, 1_000.0)), None);
307    }
308
309    #[test]
310    fn batch_equals_streaming() {
311        let candles: Vec<Candle> = (0..120)
312            .map(|i| {
313                let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
314                candle(base + 2.0, base - 1.5, base + 0.5, 1_000.0 + f64::from(i))
315            })
316            .collect();
317        let batch = TwiggsMoneyFlow::new(21).unwrap().batch(&candles);
318        let mut b = TwiggsMoneyFlow::new(21).unwrap();
319        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
320        assert_eq!(batch, streamed);
321    }
322}