wickra_core/indicators/
tsf.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::ShiftedTrend;
7use crate::traits::Indicator;
8
9#[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 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 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 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 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 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 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 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 #[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}