Skip to main content

wickra_core/indicators/
instantaneous_trendline.rs

1//! Ehlers Instantaneous Trendline (ITrend).
2#![allow(clippy::doc_markdown)]
3
4use crate::error::{Error, Result};
5use crate::traits::Indicator;
6
7/// Ehlers' Instantaneous Trendline (ITrend).
8///
9/// A 2-pole IIR that approximates a lag-free trend line:
10///
11/// ```text
12/// itrend[t] = (alpha - alpha^2/4) * x[t]
13///           + 0.5 * alpha^2 * x[t-1]
14///           - (alpha - 0.75*alpha^2) * x[t-2]
15///           + 2*(1 - alpha) * itrend[t-1]
16///           - (1 - alpha)^2 * itrend[t-2]
17/// ```
18///
19/// where `alpha = 2 / (period + 1)`. From *Cybernetic Analysis for Stocks
20/// and Futures* (Ehlers 2004, ch. 8). During the first six bars the output
21/// uses the EasyLanguage initial condition `(x[t] + 2*x[t-1] + x[t-2]) / 4`.
22///
23/// # Example
24///
25/// ```
26/// use wickra_core::{Indicator, InstantaneousTrendline};
27///
28/// let mut it = InstantaneousTrendline::new(20).unwrap();
29/// let mut last = None;
30/// for i in 0..40 {
31///     last = it.update(100.0 + f64::from(i) * 0.5);
32/// }
33/// assert!(last.is_some());
34/// ```
35#[derive(Debug, Clone)]
36pub struct InstantaneousTrendline {
37    period: usize,
38    alpha: f64,
39    in_buf: [Option<f64>; 3],
40    out_buf: [Option<f64>; 2],
41    count: usize,
42    last_value: Option<f64>,
43}
44
45impl InstantaneousTrendline {
46    /// Construct with the dominant-cycle period.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`Error::PeriodZero`] if `period == 0`.
51    pub fn new(period: usize) -> Result<Self> {
52        if period == 0 {
53            return Err(Error::PeriodZero);
54        }
55        if period > crate::error::MAX_PERIOD {
56            return Err(Error::InvalidPeriod {
57                message: crate::error::PERIOD_ABOVE_MAX,
58            });
59        }
60        let alpha = 2.0 / (period as f64 + 1.0);
61        Ok(Self {
62            period,
63            alpha,
64            in_buf: [None; 3],
65            out_buf: [None; 2],
66            count: 0,
67            last_value: None,
68        })
69    }
70
71    /// Configured period.
72    pub const fn period(&self) -> usize {
73        self.period
74    }
75
76    /// Smoothing alpha.
77    pub const fn alpha(&self) -> f64 {
78        self.alpha
79    }
80
81    /// Current value if available.
82    pub const fn value(&self) -> Option<f64> {
83        self.last_value
84    }
85}
86
87impl Indicator for InstantaneousTrendline {
88    type Input = f64;
89    type Output = f64;
90
91    fn update(&mut self, input: f64) -> Option<f64> {
92        if !input.is_finite() {
93            return None;
94        }
95        self.count += 1;
96
97        // Shift input buffer (position 0 = most recent).
98        self.in_buf[2] = self.in_buf[1];
99        self.in_buf[1] = self.in_buf[0];
100        self.in_buf[0] = Some(input);
101
102        let alpha = self.alpha;
103        let v = if self.count >= 7 {
104            // Full recursive formula.
105            let (x0, x1, x2) = (
106                self.in_buf[0].expect("filled"),
107                self.in_buf[1].expect("filled"),
108                self.in_buf[2].expect("filled"),
109            );
110            let (y1, y2) = (
111                self.out_buf[0].expect("filled"),
112                self.out_buf[1].expect("filled"),
113            );
114            (alpha - alpha * alpha / 4.0) * x0 + 0.5 * alpha * alpha * x1
115                - (alpha - 0.75 * alpha * alpha) * x2
116                + 2.0 * (1.0 - alpha) * y1
117                - (1.0 - alpha) * (1.0 - alpha) * y2
118        } else {
119            // Initial condition: 4-point weighted average of the most recent
120            // inputs (Ehlers EasyLanguage default).
121            let x0 = self.in_buf[0].expect("just pushed");
122            let x1 = self.in_buf[1].unwrap_or(x0);
123            let x2 = self.in_buf[2].unwrap_or(x0);
124            (x0 + 2.0 * x1 + x2) / 4.0
125        };
126
127        self.out_buf[1] = self.out_buf[0];
128        self.out_buf[0] = Some(v);
129        self.last_value = Some(v);
130        Some(v)
131    }
132
133    fn reset(&mut self) {
134        self.in_buf = [None; 3];
135        self.out_buf = [None; 2];
136        self.count = 0;
137        self.last_value = None;
138    }
139
140    #[inline]
141    fn warmup_period(&self) -> usize {
142        1
143    }
144
145    #[inline]
146    fn is_ready(&self) -> bool {
147        self.last_value.is_some()
148    }
149
150    #[inline]
151    fn name(&self) -> &'static str {
152        "InstantaneousTrendline"
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::traits::BatchExt;
160    use approx::assert_relative_eq;
161
162    #[test]
163    fn new_rejects_zero_period() {
164        assert!(matches!(
165            InstantaneousTrendline::new(0),
166            Err(Error::PeriodZero)
167        ));
168    }
169
170    #[test]
171    fn accessors_and_metadata() {
172        let mut it = InstantaneousTrendline::new(20).unwrap();
173        assert_eq!(it.period(), 20);
174        assert_relative_eq!(it.alpha(), 2.0 / 21.0, epsilon = 1e-15);
175        assert_eq!(it.warmup_period(), 1);
176        assert_eq!(it.name(), "InstantaneousTrendline");
177        assert!(!it.is_ready());
178        it.update(100.0);
179        assert!(it.is_ready());
180    }
181
182    #[test]
183    fn constant_series_passes_through() {
184        // Coefficients sum to 1, so a flat input stays flat after warmup.
185        let mut it = InstantaneousTrendline::new(20).unwrap();
186        let out = it.batch(&[42.0_f64; 200]);
187        for x in out.iter().skip(20).flatten() {
188            assert_relative_eq!(*x, 42.0, epsilon = 1e-6);
189        }
190    }
191
192    #[test]
193    fn batch_equals_streaming() {
194        let prices: Vec<f64> = (0..120)
195            .map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 5.0)
196            .collect();
197        let mut a = InstantaneousTrendline::new(15).unwrap();
198        let mut b = InstantaneousTrendline::new(15).unwrap();
199        let batch = a.batch(&prices);
200        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
201        assert_eq!(batch, streamed);
202    }
203
204    #[test]
205    fn ignores_non_finite_input() {
206        let mut it = InstantaneousTrendline::new(20).unwrap();
207        it.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
208        let before = it.value();
209        assert!(before.is_some());
210        assert_eq!(it.update(f64::NAN), None);
211    }
212
213    #[test]
214    fn reset_clears_state() {
215        let mut it = InstantaneousTrendline::new(20).unwrap();
216        it.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
217        assert!(it.is_ready());
218        it.reset();
219        assert!(!it.is_ready());
220    }
221}