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::indicators::rolling_moments::ShiftedTrend;
7use crate::traits::Indicator;
8
9/// Time Series Forecast (`TSF`): the rolling least-squares line projected one bar
10/// past the window.
11///
12/// Over the last `period` inputs, indexed `x = 0, 1, …, period − 1`, it fits
13/// `y = a + b·x` by ordinary least squares and reports the line's value at
14/// `x = period` (one step beyond the most recent point):
15///
16/// ```text
17/// b (slope)     = (n·Σxy − Σx·Σy) / (n·Σxx − (Σx)²)
18/// a (intercept) = (Σy − b·Σx) / n
19/// TSF           = a + b·period
20/// ```
21///
22/// Where [`LinearRegression`](crate::LinearRegression) evaluates the fit at the
23/// current bar (`a + b·(period − 1)`), `TSF` advances it one further bar, giving a
24/// trend-following one-step-ahead forecast. Each update is O(1).
25///
26/// # Example
27///
28/// ```
29/// use wickra_core::{Indicator, Tsf};
30///
31/// let mut indicator = Tsf::new(14).unwrap();
32/// let mut last = None;
33/// for i in 0..80 {
34///     last = indicator.update(f64::from(i));
35/// }
36/// assert!(last.is_some());
37/// ```
38#[derive(Debug, Clone)]
39pub struct Tsf {
40    period: usize,
41    window: VecDeque<f64>,
42    sum_x: f64,
43    denom: f64,
44    trend: ShiftedTrend,
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        if period > crate::error::MAX_PERIOD {
60            return Err(Error::InvalidPeriod {
61                message: crate::error::PERIOD_ABOVE_MAX,
62            });
63        }
64        let n = period as f64;
65        let sum_x = n * (n - 1.0) / 2.0;
66        let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
67        Ok(Self {
68            period,
69            window: VecDeque::with_capacity(period),
70            sum_x,
71            denom: n * sum_xx - sum_x * sum_x,
72            trend: ShiftedTrend::new(),
73        })
74    }
75
76    /// Configured period.
77    pub const fn period(&self) -> usize {
78        self.period
79    }
80}
81
82impl Indicator for Tsf {
83    type Input = f64;
84    type Output = f64;
85
86    #[inline]
87    fn update(&mut self, value: f64) -> Option<f64> {
88        if !value.is_finite() {
89            return None;
90        }
91        if self.window.len() == self.period {
92            let front = self.window.pop_front().expect("non-empty");
93            self.trend.slide(front);
94        }
95        let index = self.window.len();
96        self.window.push_back(value);
97        self.trend.push(value, index);
98        if self.trend.needs_reseed(self.period) {
99            self.trend.reseed(self.window.iter().copied());
100        }
101
102        if self.window.len() < self.period {
103            return None;
104        }
105        let n = self.period as f64;
106        let slope = (n * self.trend.sum_xy() - self.sum_x * self.trend.sum_y()) / self.denom;
107        // A forecast of an absolute price level, so the reference point comes
108        // back here; the slope it is projected along is invariant.
109        let intercept = (self.trend.sum_y() - slope * self.sum_x) / n + self.trend.offset();
110        Some(intercept + slope * n)
111    }
112
113    fn reset(&mut self) {
114        self.window.clear();
115        self.trend.reset();
116    }
117
118    #[inline]
119    fn warmup_period(&self) -> usize {
120        self.period
121    }
122
123    #[inline]
124    fn is_ready(&self) -> bool {
125        self.window.len() == self.period
126    }
127
128    #[inline]
129    fn name(&self) -> &'static str {
130        "TSF"
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::traits::BatchExt;
138    use approx::assert_relative_eq;
139
140    #[test]
141    fn rejects_short_period() {
142        assert!(matches!(Tsf::new(1), Err(Error::InvalidPeriod { .. })));
143    }
144
145    #[test]
146    fn accessors_report_config() {
147        let tsf = Tsf::new(5).unwrap();
148        assert_eq!(tsf.period(), 5);
149        assert_eq!(tsf.name(), "TSF");
150        assert_eq!(tsf.warmup_period(), 5);
151        assert!(!tsf.is_ready());
152    }
153
154    #[test]
155    fn reference_value() {
156        // period 3 over [1, 2, 9]: fit y = 0 + 4x, forecast at x = 3 is 12.
157        let mut tsf = Tsf::new(3).unwrap();
158        let out: Vec<Option<f64>> = tsf.batch(&[1.0, 2.0, 9.0]);
159        assert!(out[0].is_none());
160        assert!(out[1].is_none());
161        assert_relative_eq!(out[2].unwrap(), 12.0, epsilon = 1e-9);
162        assert!(tsf.is_ready());
163    }
164
165    #[test]
166    fn forecasts_a_clean_line_one_step_ahead() {
167        // Window [10, 12, 14]: y = 10 + 2x, forecast at x = 3 is 16.
168        let mut tsf = Tsf::new(3).unwrap();
169        let out: Vec<Option<f64>> = tsf.batch(&[1.0, 10.0, 12.0, 14.0]);
170        assert_relative_eq!(out[3].unwrap(), 16.0, epsilon = 1e-9);
171    }
172
173    #[test]
174    fn reset_clears_state() {
175        let mut tsf = Tsf::new(3).unwrap();
176        let _ = tsf.batch(&[1.0, 2.0, 9.0]);
177        assert!(tsf.is_ready());
178        tsf.reset();
179        assert!(!tsf.is_ready());
180        assert_eq!(tsf.update(1.0), None);
181    }
182
183    /// Least-squares fit of a window against its own index, computed entirely
184    /// on deviations. Returns `(slope, mean, sse)`.
185    ///
186    /// Forming the residuals as `y - (intercept + slope*i)` instead, with both
187    /// sides the size of the price, is exactly what this file stopped doing:
188    /// at a price level of 1e8 that subtraction alone costs eight digits. On
189    /// the centred scale the fitted line is just `slope * (i - mean_x)`.
190    fn centred_fit(window: &[f64]) -> (f64, f64, f64) {
191        let n = window.len() as f64;
192        let mean = window.iter().sum::<f64>() / n;
193        let mean_x = (n - 1.0) / 2.0;
194        let (mut sxy, mut sxx) = (0.0, 0.0);
195        for (i, &y) in window.iter().enumerate() {
196            let dx = i as f64 - mean_x;
197            sxy += dx * (y - mean);
198            sxx += dx * dx;
199        }
200        let slope = sxy / sxx;
201        let mut sse = 0.0;
202        for (i, &y) in window.iter().enumerate() {
203            let r = (y - mean) - slope * (i as f64 - mean_x);
204            sse += r * r;
205        }
206        (slope, mean, sse)
207    }
208
209    /// A one-unit wobble on top of a large price level: the level-to-deviation
210    /// ratio is what drives the cancellation, and scaling the wobble with the
211    /// level instead keeps that ratio constant and hides the defect entirely.
212    fn high_level_series(bars: usize) -> Vec<f64> {
213        (0..bars)
214            .map(|i| {
215                let t = i as f64;
216                1e8 + ((t * 0.11).sin() + 0.4 * (t * 0.37).cos())
217            })
218            .collect()
219    }
220
221    /// The forecast is an absolute price level, so it restores the reference
222    /// point the sums are held relative to. Matching a centred fit at a level
223    /// of 1e8 pins that restoration together with the slope it projects along.
224    #[test]
225    fn forecast_at_a_high_price_level_matches_a_centred_fit() {
226        const P: usize = 20;
227        let data = high_level_series(400);
228        let mut ind = Tsf::new(P).unwrap();
229        let mut compared = 0_usize;
230        for (i, &v) in data.iter().enumerate() {
231            let Some(forecast) = ind.update(v) else {
232                continue;
233            };
234            let (slope, mean, _) = centred_fit(&data[i + 1 - P..=i]);
235            let mean_x = (P as f64 - 1.0) / 2.0;
236            let want = mean + slope * (P as f64 - mean_x);
237            compared += 1;
238            assert_relative_eq!(forecast, want, max_relative = 1e-14);
239        }
240        assert_eq!(compared, data.len() - ind.warmup_period() + 1);
241    }
242}