wickra_core/indicators/
holt_winters.rs1use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6#[derive(Debug, Clone)]
43pub struct HoltWinters {
44 alpha: f64,
45 beta: f64,
46 state: Option<(f64, f64)>,
48 prev_price: Option<f64>,
50}
51
52impl HoltWinters {
53 pub fn new(alpha: f64, beta: f64) -> Result<Self> {
61 if !alpha.is_finite() || alpha <= 0.0 || alpha > 1.0 {
62 return Err(Error::InvalidPeriod {
63 message: "HoltWinters alpha must be in (0.0, 1.0]",
64 });
65 }
66 if !beta.is_finite() || beta <= 0.0 || beta > 1.0 {
67 return Err(Error::InvalidPeriod {
68 message: "HoltWinters beta must be in (0.0, 1.0]",
69 });
70 }
71 Ok(Self {
72 alpha,
73 beta,
74 state: None,
75 prev_price: None,
76 })
77 }
78
79 pub const fn alpha(&self) -> f64 {
81 self.alpha
82 }
83
84 pub const fn beta(&self) -> f64 {
86 self.beta
87 }
88
89 pub fn level(&self) -> Option<f64> {
91 self.state.map(|(level, _)| level)
92 }
93
94 pub fn trend(&self) -> Option<f64> {
96 self.state.map(|(_, trend)| trend)
97 }
98
99 pub fn value(&self) -> Option<f64> {
101 self.state.map(|(level, trend)| level + trend)
102 }
103}
104
105impl Indicator for HoltWinters {
106 type Input = f64;
107 type Output = f64;
108
109 #[inline]
110 fn update(&mut self, price: f64) -> Option<f64> {
111 if !price.is_finite() {
112 return None;
113 }
114 match self.state {
115 None => {
116 if let Some(prev) = self.prev_price {
117 let level = price;
119 let trend = price - prev;
120 self.state = Some((level, trend));
121 Some(level + trend)
122 } else {
123 self.prev_price = Some(price);
125 None
126 }
127 }
128 Some((level, trend)) => {
129 let level_new = self.alpha * price + (1.0 - self.alpha) * (level + trend);
130 let trend_new = self.beta * (level_new - level) + (1.0 - self.beta) * trend;
131 self.state = Some((level_new, trend_new));
132 Some(level_new + trend_new)
133 }
134 }
135 }
136
137 fn reset(&mut self) {
138 self.state = None;
139 self.prev_price = None;
140 }
141
142 #[inline]
143 fn warmup_period(&self) -> usize {
144 2
146 }
147
148 #[inline]
149 fn is_ready(&self) -> bool {
150 self.state.is_some()
151 }
152
153 #[inline]
154 fn name(&self) -> &'static str {
155 "HoltWinters"
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::traits::BatchExt;
163 use approx::assert_relative_eq;
164
165 fn naive(prices: &[f64], alpha: f64, beta: f64) -> Vec<Option<f64>> {
167 let mut state: Option<(f64, f64)> = None;
168 let mut prev: Option<f64> = None;
169 let mut out = Vec::with_capacity(prices.len());
170 for &price in prices {
171 let v = match state {
172 None => {
173 if let Some(p0) = prev {
174 let level = price;
175 let trend = price - p0;
176 state = Some((level, trend));
177 Some(level + trend)
178 } else {
179 prev = Some(price);
180 None
181 }
182 }
183 Some((level, trend)) => {
184 let ln = alpha * price + (1.0 - alpha) * (level + trend);
185 let tn = beta * (ln - level) + (1.0 - beta) * trend;
186 state = Some((ln, tn));
187 Some(ln + tn)
188 }
189 };
190 out.push(v);
191 }
192 out
193 }
194
195 #[test]
196 fn rejects_invalid_alpha() {
197 assert!(matches!(
198 HoltWinters::new(0.0, 0.1),
199 Err(Error::InvalidPeriod { .. })
200 ));
201 assert!(matches!(
202 HoltWinters::new(1.5, 0.1),
203 Err(Error::InvalidPeriod { .. })
204 ));
205 assert!(matches!(
206 HoltWinters::new(f64::NAN, 0.1),
207 Err(Error::InvalidPeriod { .. })
208 ));
209 }
210
211 #[test]
212 fn rejects_invalid_beta() {
213 assert!(matches!(
214 HoltWinters::new(0.2, 0.0),
215 Err(Error::InvalidPeriod { .. })
216 ));
217 assert!(matches!(
218 HoltWinters::new(0.2, 1.5),
219 Err(Error::InvalidPeriod { .. })
220 ));
221 assert!(matches!(
222 HoltWinters::new(0.2, f64::INFINITY),
223 Err(Error::InvalidPeriod { .. })
224 ));
225 }
226
227 #[test]
230 fn accessors_and_metadata() {
231 let hw = HoltWinters::new(0.2, 0.1).unwrap();
232 assert_relative_eq!(hw.alpha(), 0.2, epsilon = 1e-12);
233 assert_relative_eq!(hw.beta(), 0.1, epsilon = 1e-12);
234 assert_eq!(hw.warmup_period(), 2);
235 assert_eq!(hw.name(), "HoltWinters");
236 }
237
238 #[test]
239 fn warmup_then_seed_on_second_input() {
240 let mut hw = HoltWinters::new(0.2, 0.1).unwrap();
241 assert_eq!(hw.update(10.0), None);
242 assert_relative_eq!(hw.update(12.0).unwrap(), 14.0, epsilon = 1e-12);
244 assert_relative_eq!(hw.level().unwrap(), 12.0, epsilon = 1e-12);
245 assert_relative_eq!(hw.trend().unwrap(), 2.0, epsilon = 1e-12);
246 }
247
248 #[test]
249 fn linear_series_forecasts_exactly() {
250 let prices: Vec<f64> = (1..=20).map(f64::from).collect();
253 let mut hw = HoltWinters::new(0.3, 0.4).unwrap();
254 let out = hw.batch(&prices);
255 assert!(out[0].is_none());
256 for (i, v) in out.iter().enumerate().skip(1) {
257 assert_relative_eq!(v.unwrap(), (i + 2) as f64, epsilon = 1e-9);
259 }
260 }
261
262 #[test]
263 fn constant_series_yields_constant() {
264 let mut hw = HoltWinters::new(0.2, 0.1).unwrap();
265 let out = hw.batch(&[42.0_f64; 30]);
266 for v in out.into_iter().skip(1).flatten() {
267 assert_relative_eq!(v, 42.0, epsilon = 1e-9);
268 }
269 }
270
271 #[test]
272 fn matches_naive_recurrence() {
273 let prices: Vec<f64> = (0..60)
274 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0 + f64::from(i) * 0.2)
275 .collect();
276 let mut hw = HoltWinters::new(0.25, 0.15).unwrap();
277 let got = hw.batch(&prices);
278 let want = naive(&prices, 0.25, 0.15);
279 for (g, w) in got.iter().zip(want.iter()) {
280 assert_eq!(g.is_some(), w.is_some());
281 if let (Some(a), Some(b)) = (g, w) {
282 assert_relative_eq!(a, b, epsilon = 1e-9);
283 }
284 }
285 }
286
287 #[test]
288 fn reset_clears_state() {
289 let mut hw = HoltWinters::new(0.2, 0.1).unwrap();
290 hw.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
291 assert!(hw.is_ready());
292 hw.reset();
293 assert!(!hw.is_ready());
294 assert_eq!(hw.update(1.0), None);
295 }
296
297 #[test]
298 fn batch_equals_streaming() {
299 let prices: Vec<f64> = (1..=30).map(|i| f64::from(i) * 0.5).collect();
300 let mut a = HoltWinters::new(0.3, 0.2).unwrap();
301 let mut b = HoltWinters::new(0.3, 0.2).unwrap();
302 assert_eq!(
303 a.batch(&prices),
304 prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
305 );
306 }
307
308 #[test]
309 fn ignores_non_finite_input() {
310 let mut hw = HoltWinters::new(0.2, 0.1).unwrap();
311 assert_eq!(hw.update(f64::NAN), None);
313 hw.update(10.0);
314 hw.update(12.0).expect("seeded on second finite input");
315 assert_eq!(hw.update(f64::NAN), None);
317 assert_eq!(hw.update(f64::INFINITY), None);
318 }
319}