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        let alpha = 2.0 / (period as f64 + 1.0);
56        Ok(Self {
57            period,
58            alpha,
59            in_buf: [None; 3],
60            out_buf: [None; 2],
61            count: 0,
62            last_value: None,
63        })
64    }
65
66    /// Configured period.
67    pub const fn period(&self) -> usize {
68        self.period
69    }
70
71    /// Smoothing alpha.
72    pub const fn alpha(&self) -> f64 {
73        self.alpha
74    }
75
76    /// Current value if available.
77    pub const fn value(&self) -> Option<f64> {
78        self.last_value
79    }
80}
81
82impl Indicator for InstantaneousTrendline {
83    type Input = f64;
84    type Output = f64;
85
86    fn update(&mut self, input: f64) -> Option<f64> {
87        if !input.is_finite() {
88            return self.last_value;
89        }
90        self.count += 1;
91
92        // Shift input buffer (position 0 = most recent).
93        self.in_buf[2] = self.in_buf[1];
94        self.in_buf[1] = self.in_buf[0];
95        self.in_buf[0] = Some(input);
96
97        let alpha = self.alpha;
98        let v = if self.count >= 7 {
99            // Full recursive formula.
100            let (x0, x1, x2) = (
101                self.in_buf[0].expect("filled"),
102                self.in_buf[1].expect("filled"),
103                self.in_buf[2].expect("filled"),
104            );
105            let (y1, y2) = (
106                self.out_buf[0].expect("filled"),
107                self.out_buf[1].expect("filled"),
108            );
109            (alpha - alpha * alpha / 4.0) * x0 + 0.5 * alpha * alpha * x1
110                - (alpha - 0.75 * alpha * alpha) * x2
111                + 2.0 * (1.0 - alpha) * y1
112                - (1.0 - alpha) * (1.0 - alpha) * y2
113        } else {
114            // Initial condition: 4-point weighted average of the most recent
115            // inputs (Ehlers EasyLanguage default).
116            let x0 = self.in_buf[0].expect("just pushed");
117            let x1 = self.in_buf[1].unwrap_or(x0);
118            let x2 = self.in_buf[2].unwrap_or(x0);
119            (x0 + 2.0 * x1 + x2) / 4.0
120        };
121
122        self.out_buf[1] = self.out_buf[0];
123        self.out_buf[0] = Some(v);
124        self.last_value = Some(v);
125        Some(v)
126    }
127
128    fn reset(&mut self) {
129        self.in_buf = [None; 3];
130        self.out_buf = [None; 2];
131        self.count = 0;
132        self.last_value = None;
133    }
134
135    fn warmup_period(&self) -> usize {
136        1
137    }
138
139    fn is_ready(&self) -> bool {
140        self.last_value.is_some()
141    }
142
143    fn name(&self) -> &'static str {
144        "InstantaneousTrendline"
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::traits::BatchExt;
152    use approx::assert_relative_eq;
153
154    #[test]
155    fn new_rejects_zero_period() {
156        assert!(matches!(
157            InstantaneousTrendline::new(0),
158            Err(Error::PeriodZero)
159        ));
160    }
161
162    #[test]
163    fn accessors_and_metadata() {
164        let mut it = InstantaneousTrendline::new(20).unwrap();
165        assert_eq!(it.period(), 20);
166        assert_relative_eq!(it.alpha(), 2.0 / 21.0, epsilon = 1e-15);
167        assert_eq!(it.warmup_period(), 1);
168        assert_eq!(it.name(), "InstantaneousTrendline");
169        assert!(!it.is_ready());
170        it.update(100.0);
171        assert!(it.is_ready());
172    }
173
174    #[test]
175    fn constant_series_passes_through() {
176        // Coefficients sum to 1, so a flat input stays flat after warmup.
177        let mut it = InstantaneousTrendline::new(20).unwrap();
178        let out = it.batch(&[42.0_f64; 200]);
179        for x in out.iter().skip(20).flatten() {
180            assert_relative_eq!(*x, 42.0, epsilon = 1e-6);
181        }
182    }
183
184    #[test]
185    fn batch_equals_streaming() {
186        let prices: Vec<f64> = (0..120)
187            .map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 5.0)
188            .collect();
189        let mut a = InstantaneousTrendline::new(15).unwrap();
190        let mut b = InstantaneousTrendline::new(15).unwrap();
191        let batch = a.batch(&prices);
192        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
193        assert_eq!(batch, streamed);
194    }
195
196    #[test]
197    fn ignores_non_finite_input() {
198        let mut it = InstantaneousTrendline::new(20).unwrap();
199        it.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
200        let before = it.value();
201        assert!(before.is_some());
202        assert_eq!(it.update(f64::NAN), before);
203    }
204
205    #[test]
206    fn reset_clears_state() {
207        let mut it = InstantaneousTrendline::new(20).unwrap();
208        it.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
209        assert!(it.is_ready());
210        it.reset();
211        assert!(!it.is_ready());
212    }
213}