Skip to main content

wickra_core/indicators/
ttm_trend.rs

1//! TTM Trend — John Carter's bar-coloring trend filter.
2
3use crate::error::Result;
4use crate::indicators::sma::Sma;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// TTM Trend: compares the current close to the simple moving average of the
9/// recent median prices `(high + low) / 2`. A close above that reference colors
10/// the bar as an uptrend (`+1.0`); a close at or below it as a downtrend
11/// (`-1.0`).
12///
13/// ```text
14/// reference = SMA((high + low) / 2, period)
15/// TTM Trend = +1  if close > reference
16///             -1  otherwise
17/// ```
18///
19/// The classic TTM Trend uses the trailing six bars. The signal is a regime
20/// label rather than a level: it stays `None` during warmup and then emits
21/// `±1.0` on every bar.
22///
23/// Reference: John Carter, *Mastering the Trade*, 2005.
24///
25/// # Example
26///
27/// ```
28/// use wickra_core::{Candle, Indicator, TtmTrend};
29///
30/// let mut indicator = TtmTrend::new(6).unwrap();
31/// let mut last = None;
32/// for i in 0..20 {
33///     let base = 100.0 + f64::from(i);
34///     let candle =
35///         Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1.0, i64::from(i)).unwrap();
36///     last = indicator.update(candle);
37/// }
38/// assert_eq!(last, Some(1.0));
39/// ```
40#[derive(Debug, Clone)]
41pub struct TtmTrend {
42    period: usize,
43    sma: Sma,
44}
45
46impl TtmTrend {
47    /// Construct a TTM Trend over the given lookback.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`Error::PeriodZero`](crate::error::Error::PeriodZero) if `period == 0`.
52    pub fn new(period: usize) -> Result<Self> {
53        Ok(Self {
54            period,
55            sma: Sma::new(period)?,
56        })
57    }
58
59    /// Configured lookback period.
60    pub const fn period(&self) -> usize {
61        self.period
62    }
63}
64
65impl Indicator for TtmTrend {
66    type Input = Candle;
67    type Output = f64;
68
69    #[inline]
70    fn update(&mut self, candle: Candle) -> Option<f64> {
71        let median = f64::midpoint(candle.high, candle.low);
72        let reference = self.sma.update(median)?;
73        Some(if candle.close > reference { 1.0 } else { -1.0 })
74    }
75
76    fn reset(&mut self) {
77        self.sma.reset();
78    }
79
80    #[inline]
81    fn warmup_period(&self) -> usize {
82        self.period
83    }
84
85    #[inline]
86    fn is_ready(&self) -> bool {
87        self.sma.is_ready()
88    }
89
90    #[inline]
91    fn name(&self) -> &'static str {
92        "TtmTrend"
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::error::Error;
100    use crate::traits::BatchExt;
101
102    fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
103        Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
104    }
105
106    #[test]
107    fn rejects_zero_period() {
108        assert!(matches!(TtmTrend::new(0), Err(Error::PeriodZero)));
109    }
110
111    #[test]
112    fn accessors_and_metadata() {
113        let t = TtmTrend::new(6).unwrap();
114        assert_eq!(t.period(), 6);
115        assert_eq!(t.warmup_period(), 6);
116        assert_eq!(t.name(), "TtmTrend");
117        assert!(!t.is_ready());
118    }
119
120    #[test]
121    fn warmup_then_emits() {
122        let mut t = TtmTrend::new(3).unwrap();
123        let candles: Vec<Candle> = (0..3).map(|i| candle(13.0, 9.0, 12.0, i)).collect();
124        let out = t.batch(&candles);
125        assert!(out[0].is_none());
126        assert!(out[1].is_none());
127        assert!(out[2].is_some());
128    }
129
130    #[test]
131    fn close_above_reference_is_uptrend() {
132        // Close (12) sits above the median reference (13 + 9) / 2 = 11 -> +1.
133        let mut t = TtmTrend::new(3).unwrap();
134        let candles: Vec<Candle> = (0..6).map(|i| candle(13.0, 9.0, 12.0, i)).collect();
135        assert_eq!(t.batch(&candles).last().unwrap().unwrap(), 1.0);
136    }
137
138    #[test]
139    fn close_at_or_below_reference_is_downtrend() {
140        // Constant median 10, close equal to the reference -> not strictly above -> -1.
141        let mut t = TtmTrend::new(3).unwrap();
142        let candles: Vec<Candle> = (0..6).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
143        assert_eq!(t.batch(&candles).last().unwrap().unwrap(), -1.0);
144    }
145
146    #[test]
147    fn reset_clears_state() {
148        let mut t = TtmTrend::new(3).unwrap();
149        let candles: Vec<Candle> = (0..6).map(|i| candle(13.0, 9.0, 12.0, i)).collect();
150        t.batch(&candles);
151        assert!(t.is_ready());
152        t.reset();
153        assert!(!t.is_ready());
154    }
155
156    #[test]
157    fn batch_equals_streaming() {
158        let candles: Vec<Candle> = (0..40_i64)
159            .map(|i| {
160                let base = 100.0 + (i as f64 * 0.25).sin() * 4.0;
161                candle(base + 1.0, base - 1.0, base + (i as f64 * 0.5).cos(), i)
162            })
163            .collect();
164        let mut a = TtmTrend::new(6).unwrap();
165        let mut b = TtmTrend::new(6).unwrap();
166        assert_eq!(
167            a.batch(&candles),
168            candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
169        );
170    }
171}