wickra_core/indicators/
trend_strength_index.rs1use std::collections::VecDeque;
5
6use crate::error::{Error, Result};
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
40pub struct TrendStrengthIndex {
41 period: usize,
42 buf: VecDeque<f64>,
43}
44
45impl TrendStrengthIndex {
46 pub fn new(period: usize) -> Result<Self> {
53 if period == 0 {
54 return Err(Error::PeriodZero);
55 }
56 if period > crate::error::MAX_PERIOD {
57 return Err(Error::InvalidPeriod {
58 message: crate::error::PERIOD_ABOVE_MAX,
59 });
60 }
61 if period == 1 {
62 return Err(Error::InvalidPeriod {
63 message: "period must be >= 2 for a regression",
64 });
65 }
66 Ok(Self {
67 period,
68 buf: VecDeque::with_capacity(period),
69 })
70 }
71
72 pub const fn period(&self) -> usize {
74 self.period
75 }
76}
77
78impl Indicator for TrendStrengthIndex {
79 type Input = f64;
80 type Output = f64;
81
82 #[inline]
83 fn update(&mut self, price: f64) -> Option<f64> {
84 if !price.is_finite() {
85 return None;
86 }
87 self.buf.push_back(price);
88 if self.buf.len() > self.period {
89 self.buf.pop_front();
90 }
91 if self.buf.len() < self.period {
92 return None;
93 }
94
95 let count = self.period as f64;
96 let mean_y = self.buf.iter().sum::<f64>() / count;
103 let mut sum_x = 0.0;
104 let mut sum_xx = 0.0;
105 let mut sum_y = 0.0;
106 let mut sum_yy = 0.0;
107 let mut sum_xy = 0.0;
108 for (idx, &price) in self.buf.iter().enumerate() {
109 let x = idx as f64;
110 let y = price - mean_y;
111 sum_x += x;
112 sum_xx += x * x;
113 sum_y += y;
114 sum_yy += y * y;
115 sum_xy += x * y;
116 }
117
118 let cov = count.mul_add(sum_xy, -(sum_x * sum_y));
119 let var_x = count.mul_add(sum_xx, -(sum_x * sum_x));
120 let var_y = count.mul_add(sum_yy, -(sum_y * sum_y));
121 if var_y <= 0.0 {
122 return Some(0.0);
123 }
124 let r2 = (cov * cov) / (var_x * var_y);
125 Some(if cov >= 0.0 { r2 } else { -r2 })
126 }
127
128 fn reset(&mut self) {
129 self.buf.clear();
130 }
131
132 #[inline]
133 fn warmup_period(&self) -> usize {
134 self.period
135 }
136
137 #[inline]
138 fn is_ready(&self) -> bool {
139 self.buf.len() >= self.period
140 }
141
142 #[inline]
143 fn name(&self) -> &'static str {
144 "TrendStrengthIndex"
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 rejects_invalid_period() {
156 assert!(matches!(TrendStrengthIndex::new(0), Err(Error::PeriodZero)));
157 assert!(matches!(
158 TrendStrengthIndex::new(1),
159 Err(Error::InvalidPeriod { .. })
160 ));
161 }
162
163 #[test]
164 fn accessors_and_metadata() {
165 let tsi = TrendStrengthIndex::new(20).unwrap();
166 assert_eq!(tsi.period(), 20);
167 assert_eq!(tsi.warmup_period(), 20);
168 assert_eq!(tsi.name(), "TrendStrengthIndex");
169 assert!(!tsi.is_ready());
170 }
171
172 #[test]
173 fn warmup_emits_at_period() {
174 let mut tsi = TrendStrengthIndex::new(4).unwrap();
175 let inputs: Vec<f64> = (0..6).map(f64::from).collect();
176 let out = tsi.batch(&inputs);
177 assert!(out[2].is_none());
178 assert!(out[3].is_some());
179 }
180
181 #[test]
182 fn perfect_uptrend_is_plus_one() {
183 let mut tsi = TrendStrengthIndex::new(10).unwrap();
184 let inputs: Vec<f64> = (0..10).map(f64::from).collect();
185 let last = tsi.batch(&inputs).last().unwrap().unwrap();
186 assert_relative_eq!(last, 1.0, epsilon = 1e-9);
187 }
188
189 #[test]
190 fn perfect_downtrend_is_minus_one() {
191 let mut tsi = TrendStrengthIndex::new(10).unwrap();
192 let inputs: Vec<f64> = (0..10).map(|i| 100.0 - f64::from(i)).collect();
193 let last = tsi.batch(&inputs).last().unwrap().unwrap();
194 assert_relative_eq!(last, -1.0, epsilon = 1e-9);
195 }
196
197 #[test]
198 fn flat_market_returns_zero() {
199 let mut tsi = TrendStrengthIndex::new(8).unwrap();
200 let inputs = [42.0; 12];
201 let last = tsi.batch(&inputs).last().unwrap().unwrap();
202 assert_relative_eq!(last, 0.0, epsilon = 1e-12);
203 }
204
205 #[test]
206 fn noisy_trend_is_between() {
207 let mut tsi = TrendStrengthIndex::new(12).unwrap();
209 let inputs: Vec<f64> = (0..12)
210 .map(|i| f64::from(i) + if i % 2 == 0 { 0.0 } else { 3.0 })
211 .collect();
212 let last = tsi.batch(&inputs).last().unwrap().unwrap();
213 assert!(last > 0.0 && last < 1.0, "tsi {last} should be in (0, 1)");
214 }
215
216 #[test]
217 fn reset_clears_state() {
218 let mut tsi = TrendStrengthIndex::new(10).unwrap();
219 let inputs: Vec<f64> = (0..10).map(f64::from).collect();
220 tsi.batch(&inputs);
221 assert!(tsi.is_ready());
222 tsi.reset();
223 assert!(!tsi.is_ready());
224 }
225
226 #[test]
227 fn batch_equals_streaming() {
228 let inputs: Vec<f64> = (0..80)
229 .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
230 .collect();
231 let mut a = TrendStrengthIndex::new(15).unwrap();
232 let mut b = TrendStrengthIndex::new(15).unwrap();
233 assert_eq!(
234 a.batch(&inputs),
235 inputs.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
236 );
237 }
238
239 #[test]
247 fn trend_at_a_high_price_level_is_still_detected() {
248 const P: usize = 20;
249 let data: Vec<f64> = (0..400)
250 .map(|i| {
251 let t = f64::from(i);
252 1e8 + ((t * 0.11).sin() + 0.4 * (t * 0.37).cos())
253 })
254 .collect();
255
256 let mut ind = TrendStrengthIndex::new(P).unwrap();
257 let mean_x = (P as f64 - 1.0) / 2.0;
258 let mut compared = 0_usize;
259 let mut saw_strong_trend = false;
260 for (i, &v) in data.iter().enumerate() {
261 let Some(got) = ind.update(v) else { continue };
262 let window = &data[i + 1 - P..=i];
263 let mean_y = window.iter().sum::<f64>() / P as f64;
264 let (mut sxy, mut sxx, mut syy) = (0.0, 0.0, 0.0);
265 for (j, &y) in window.iter().enumerate() {
266 let dx = j as f64 - mean_x;
267 let dy = y - mean_y;
268 sxy += dx * dy;
269 sxx += dx * dx;
270 syy += dy * dy;
271 }
272 let r = sxy / (sxx * syy).sqrt();
273 let want = if sxy >= 0.0 { r * r } else { -(r * r) };
274 if want.abs() > 0.5 {
275 saw_strong_trend = true;
276 }
277 compared += 1;
278 assert_relative_eq!(got, want, max_relative = 1e-9);
279 }
280 assert_eq!(compared, data.len() - ind.warmup_period() + 1);
281 assert!(saw_strong_trend);
283 }
284}