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    fn update(&mut self, candle: Candle) -> Option<f64> {
70        let median = f64::midpoint(candle.high, candle.low);
71        let reference = self.sma.update(median)?;
72        Some(if candle.close > reference { 1.0 } else { -1.0 })
73    }
74
75    fn reset(&mut self) {
76        self.sma.reset();
77    }
78
79    fn warmup_period(&self) -> usize {
80        self.period
81    }
82
83    fn is_ready(&self) -> bool {
84        self.sma.is_ready()
85    }
86
87    fn name(&self) -> &'static str {
88        "TtmTrend"
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::error::Error;
96    use crate::traits::BatchExt;
97
98    fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
99        Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
100    }
101
102    #[test]
103    fn rejects_zero_period() {
104        assert!(matches!(TtmTrend::new(0), Err(Error::PeriodZero)));
105    }
106
107    #[test]
108    fn accessors_and_metadata() {
109        let t = TtmTrend::new(6).unwrap();
110        assert_eq!(t.period(), 6);
111        assert_eq!(t.warmup_period(), 6);
112        assert_eq!(t.name(), "TtmTrend");
113        assert!(!t.is_ready());
114    }
115
116    #[test]
117    fn warmup_then_emits() {
118        let mut t = TtmTrend::new(3).unwrap();
119        let candles: Vec<Candle> = (0..3).map(|i| candle(13.0, 9.0, 12.0, i)).collect();
120        let out = t.batch(&candles);
121        assert!(out[0].is_none());
122        assert!(out[1].is_none());
123        assert!(out[2].is_some());
124    }
125
126    #[test]
127    fn close_above_reference_is_uptrend() {
128        // Close (12) sits above the median reference (13 + 9) / 2 = 11 -> +1.
129        let mut t = TtmTrend::new(3).unwrap();
130        let candles: Vec<Candle> = (0..6).map(|i| candle(13.0, 9.0, 12.0, i)).collect();
131        assert_eq!(t.batch(&candles).last().unwrap().unwrap(), 1.0);
132    }
133
134    #[test]
135    fn close_at_or_below_reference_is_downtrend() {
136        // Constant median 10, close equal to the reference -> not strictly above -> -1.
137        let mut t = TtmTrend::new(3).unwrap();
138        let candles: Vec<Candle> = (0..6).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
139        assert_eq!(t.batch(&candles).last().unwrap().unwrap(), -1.0);
140    }
141
142    #[test]
143    fn reset_clears_state() {
144        let mut t = TtmTrend::new(3).unwrap();
145        let candles: Vec<Candle> = (0..6).map(|i| candle(13.0, 9.0, 12.0, i)).collect();
146        t.batch(&candles);
147        assert!(t.is_ready());
148        t.reset();
149        assert!(!t.is_ready());
150    }
151
152    #[test]
153    fn batch_equals_streaming() {
154        let candles: Vec<Candle> = (0..40_i64)
155            .map(|i| {
156                let base = 100.0 + (i as f64 * 0.25).sin() * 4.0;
157                candle(base + 1.0, base - 1.0, base + (i as f64 * 0.5).cos(), i)
158            })
159            .collect();
160        let mut a = TtmTrend::new(6).unwrap();
161        let mut b = TtmTrend::new(6).unwrap();
162        assert_eq!(
163            a.batch(&candles),
164            candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
165        );
166    }
167}