Skip to main content

wickra_core/indicators/
tsf.rs

1//! Time Series Forecast (TSF).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Time Series Forecast (`TSF`): the rolling least-squares line projected one bar
9/// past the window.
10///
11/// Over the last `period` inputs, indexed `x = 0, 1, …, period − 1`, it fits
12/// `y = a + b·x` by ordinary least squares and reports the line's value at
13/// `x = period` (one step beyond the most recent point):
14///
15/// ```text
16/// b (slope)     = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²)
17/// a (intercept) = (Σy − b·Σx) / n
18/// TSF           = a + b·period
19/// ```
20///
21/// Where [`LinearRegression`](crate::LinearRegression) evaluates the fit at the
22/// current bar (`a + b·(period − 1)`), `TSF` advances it one further bar, giving a
23/// trend-following one-step-ahead forecast. Each update is O(1).
24///
25/// # Example
26///
27/// ```
28/// use wickra_core::{Indicator, Tsf};
29///
30/// let mut indicator = Tsf::new(14).unwrap();
31/// let mut last = None;
32/// for i in 0..80 {
33///     last = indicator.update(f64::from(i));
34/// }
35/// assert!(last.is_some());
36/// ```
37#[derive(Debug, Clone)]
38pub struct Tsf {
39    period: usize,
40    window: VecDeque<f64>,
41    sum_x: f64,
42    denom: f64,
43    sum_y: f64,
44    sum_xy: f64,
45}
46
47impl Tsf {
48    /// Construct a new rolling time-series forecast over `period` inputs.
49    ///
50    /// # Errors
51    /// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is
52    /// undefined for fewer than two points.
53    pub fn new(period: usize) -> Result<Self> {
54        if period < 2 {
55            return Err(Error::InvalidPeriod {
56                message: "time series forecast needs period >= 2",
57            });
58        }
59        let n = period as f64;
60        let sum_x = n * (n - 1.0) / 2.0;
61        let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
62        Ok(Self {
63            period,
64            window: VecDeque::with_capacity(period),
65            sum_x,
66            denom: n * sum_xx - sum_x * sum_x,
67            sum_y: 0.0,
68            sum_xy: 0.0,
69        })
70    }
71
72    /// Configured period.
73    pub const fn period(&self) -> usize {
74        self.period
75    }
76}
77
78impl Indicator for Tsf {
79    type Input = f64;
80    type Output = f64;
81
82    fn update(&mut self, value: f64) -> Option<f64> {
83        if !value.is_finite() {
84            return None;
85        }
86        if self.window.len() == self.period {
87            let y0 = self.window.pop_front().expect("non-empty");
88            self.sum_xy = self.sum_xy - self.sum_y + y0;
89            self.sum_y -= y0;
90        }
91        let k = self.window.len() as f64;
92        self.window.push_back(value);
93        self.sum_y += value;
94        self.sum_xy += k * value;
95
96        if self.window.len() < self.period {
97            return None;
98        }
99        let n = self.period as f64;
100        let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom;
101        let intercept = (self.sum_y - slope * self.sum_x) / n;
102        Some(intercept + slope * n)
103    }
104
105    fn reset(&mut self) {
106        self.window.clear();
107        self.sum_y = 0.0;
108        self.sum_xy = 0.0;
109    }
110
111    fn warmup_period(&self) -> usize {
112        self.period
113    }
114
115    fn is_ready(&self) -> bool {
116        self.window.len() == self.period
117    }
118
119    fn name(&self) -> &'static str {
120        "TSF"
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::traits::BatchExt;
128    use approx::assert_relative_eq;
129
130    #[test]
131    fn rejects_short_period() {
132        assert!(matches!(Tsf::new(1), Err(Error::InvalidPeriod { .. })));
133    }
134
135    #[test]
136    fn accessors_report_config() {
137        let tsf = Tsf::new(5).unwrap();
138        assert_eq!(tsf.period(), 5);
139        assert_eq!(tsf.name(), "TSF");
140        assert_eq!(tsf.warmup_period(), 5);
141        assert!(!tsf.is_ready());
142    }
143
144    #[test]
145    fn reference_value() {
146        // period 3 over [1, 2, 9]: fit y = 0 + 4x, forecast at x = 3 is 12.
147        let mut tsf = Tsf::new(3).unwrap();
148        let out: Vec<Option<f64>> = tsf.batch(&[1.0, 2.0, 9.0]);
149        assert!(out[0].is_none());
150        assert!(out[1].is_none());
151        assert_relative_eq!(out[2].unwrap(), 12.0, epsilon = 1e-9);
152        assert!(tsf.is_ready());
153    }
154
155    #[test]
156    fn forecasts_a_clean_line_one_step_ahead() {
157        // Window [10, 12, 14]: y = 10 + 2x, forecast at x = 3 is 16.
158        let mut tsf = Tsf::new(3).unwrap();
159        let out: Vec<Option<f64>> = tsf.batch(&[1.0, 10.0, 12.0, 14.0]);
160        assert_relative_eq!(out[3].unwrap(), 16.0, epsilon = 1e-9);
161    }
162
163    #[test]
164    fn reset_clears_state() {
165        let mut tsf = Tsf::new(3).unwrap();
166        let _ = tsf.batch(&[1.0, 2.0, 9.0]);
167        assert!(tsf.is_ready());
168        tsf.reset();
169        assert!(!tsf.is_ready());
170        assert_eq!(tsf.update(1.0), None);
171    }
172}