wickra_core/indicators/
tsi.rs1use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6use super::Ema;
7
8#[derive(Debug, Clone)]
36pub struct Tsi {
37 long: usize,
38 short: usize,
39 prev_price: Option<f64>,
40 ema_long_mom: Ema,
41 ema_short_mom: Ema,
42 ema_long_abs: Ema,
43 ema_short_abs: Ema,
44 current: Option<f64>,
45}
46
47impl Tsi {
48 pub fn new(long: usize, short: usize) -> Result<Self> {
54 if long == 0 || short == 0 {
55 return Err(Error::PeriodZero);
56 }
57 Ok(Self {
58 long,
59 short,
60 prev_price: None,
61 ema_long_mom: Ema::new(long)?,
62 ema_short_mom: Ema::new(short)?,
63 ema_long_abs: Ema::new(long)?,
64 ema_short_abs: Ema::new(short)?,
65 current: None,
66 })
67 }
68
69 pub const fn periods(&self) -> (usize, usize) {
71 (self.long, self.short)
72 }
73
74 pub const fn value(&self) -> Option<f64> {
76 self.current
77 }
78}
79
80impl Indicator for Tsi {
81 type Input = f64;
82 type Output = f64;
83
84 fn update(&mut self, input: f64) -> Option<f64> {
85 if !input.is_finite() {
86 return self.current;
88 }
89 let Some(prev) = self.prev_price else {
90 self.prev_price = Some(input);
91 return None;
92 };
93 self.prev_price = Some(input);
94
95 let momentum = input - prev;
96 let ds_mom = self
97 .ema_long_mom
98 .update(momentum)
99 .and_then(|v| self.ema_short_mom.update(v));
100 let ds_abs = self
101 .ema_long_abs
102 .update(momentum.abs())
103 .and_then(|v| self.ema_short_abs.update(v));
104
105 match (ds_mom, ds_abs) {
106 (Some(m), Some(a)) => {
107 let tsi = if a == 0.0 {
108 0.0
110 } else {
111 100.0 * m / a
112 };
113 self.current = Some(tsi);
114 Some(tsi)
115 }
116 _ => None,
117 }
118 }
119
120 fn reset(&mut self) {
121 self.prev_price = None;
122 self.ema_long_mom.reset();
123 self.ema_short_mom.reset();
124 self.ema_long_abs.reset();
125 self.ema_short_abs.reset();
126 self.current = None;
127 }
128
129 fn warmup_period(&self) -> usize {
130 self.long + self.short
131 }
132
133 fn is_ready(&self) -> bool {
134 self.current.is_some()
135 }
136
137 fn name(&self) -> &'static str {
138 "TSI"
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use crate::traits::BatchExt;
146 use approx::assert_relative_eq;
147
148 #[test]
149 fn new_rejects_zero_period() {
150 assert!(matches!(Tsi::new(0, 13), Err(Error::PeriodZero)));
151 assert!(matches!(Tsi::new(25, 0), Err(Error::PeriodZero)));
152 }
153
154 #[test]
158 fn accessors_and_metadata() {
159 let mut tsi = Tsi::new(25, 13).unwrap();
160 assert_eq!(tsi.periods(), (25, 13));
161 assert_eq!(tsi.name(), "TSI");
162 assert_eq!(tsi.value(), None);
163 for i in 1..=tsi.warmup_period() {
164 tsi.update(100.0 + f64::from(u32::try_from(i).unwrap()));
165 }
166 assert!(tsi.value().is_some());
167 }
168
169 #[test]
170 fn first_emission_at_warmup_period() {
171 let mut tsi = Tsi::new(5, 3).unwrap();
172 assert_eq!(tsi.warmup_period(), 8);
173 let out = tsi.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
174 for v in out.iter().take(7) {
175 assert!(v.is_none());
176 }
177 assert!(out[7].is_some());
178 }
179
180 #[test]
181 fn pure_uptrend_saturates_at_plus_100() {
182 let mut tsi = Tsi::new(5, 3).unwrap();
184 let out = tsi.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
185 for v in out.iter().skip(8).flatten() {
186 assert_relative_eq!(*v, 100.0, epsilon = 1e-9);
187 }
188 }
189
190 #[test]
191 fn pure_downtrend_saturates_at_minus_100() {
192 let mut tsi = Tsi::new(5, 3).unwrap();
193 let out = tsi.batch(&(1..=40).rev().map(f64::from).collect::<Vec<_>>());
194 for v in out.iter().skip(8).flatten() {
195 assert_relative_eq!(*v, -100.0, epsilon = 1e-9);
196 }
197 }
198
199 #[test]
200 fn constant_series_yields_zero() {
201 let mut tsi = Tsi::new(5, 3).unwrap();
202 let out = tsi.batch(&[50.0; 40]);
203 for v in out.iter().skip(8).flatten() {
204 assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
205 }
206 }
207
208 #[test]
209 fn ignores_non_finite_input() {
210 let mut tsi = Tsi::new(5, 3).unwrap();
211 let out = tsi.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
212 let last = *out.last().unwrap();
213 assert!(last.is_some());
214 assert_eq!(tsi.update(f64::NAN), last);
215 assert_eq!(tsi.update(f64::INFINITY), last);
216 }
217
218 #[test]
219 fn reset_clears_state() {
220 let mut tsi = Tsi::new(5, 3).unwrap();
221 tsi.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
222 assert!(tsi.is_ready());
223 tsi.reset();
224 assert!(!tsi.is_ready());
225 assert_eq!(tsi.update(1.0), None);
226 }
227
228 #[test]
229 fn batch_equals_streaming() {
230 let prices: Vec<f64> = (1..=80)
231 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 9.0)
232 .collect();
233 let batch = Tsi::new(13, 7).unwrap().batch(&prices);
234 let mut b = Tsi::new(13, 7).unwrap();
235 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
236 assert_eq!(batch, streamed);
237 }
238}