kestrel_chartkit/indicator/
tsi.rs1use std::collections::HashMap;
2
3use crate::model::Bar;
4
5use super::divergence::SlopeDivergence;
6use super::smoothing::{crossed_over, crossed_under, Ema, ExtremeWindow};
7use super::{Indicator, IndicatorAlert, IndicatorOutput};
8
9pub struct Tsi {
19 mid_line: f64,
20 oversold: f64,
21 overbought: f64,
22 require_extreme_zone: bool,
23 div_len: usize,
24
25 prev_close: Option<f64>,
26 mom_long: Ema,
27 mom_short: Ema,
28 abs_long: Ema,
29 abs_short: Ema,
30 signal_avg: Ema,
31 extreme_window: ExtremeWindow,
32 prev_tsi_line: Option<f64>,
33 prev_signal: Option<f64>,
34 bars_seen: usize,
35
36 ctx_mom_long: Ema,
37 ctx_mom_short: Ema,
38 ctx_abs_long: Ema,
39 ctx_abs_short: Ema,
40 divergence: SlopeDivergence,
41
42 alerts: TsiAlerts,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Default)]
46pub struct TsiAlerts {
47 pub bull_extreme: bool,
48 pub bear_extreme: bool,
49 pub bull_mid_cross: bool,
50 pub bear_mid_cross: bool,
51 pub bull_divergence: bool,
52 pub bear_divergence: bool,
53 pub extreme_strength: f64,
54 pub divergence_strength: f64,
55}
56
57impl Tsi {
58 #[allow(clippy::too_many_arguments)]
59 pub fn new(
60 long_len: usize,
61 short_len: usize,
62 sig_len: usize,
63 mid_line: f64,
64 overbought: f64,
65 oversold: f64,
66 lookback_extreme: usize,
67 require_extreme_zone: bool,
68 ctx_long_len: usize,
69 ctx_short_len: usize,
70 div_len: usize,
71 div_min: f64,
72 ) -> Self {
73 Self {
74 mid_line,
75 oversold,
76 overbought,
77 require_extreme_zone,
78 div_len,
79 prev_close: None,
80 mom_long: Ema::new(long_len),
81 mom_short: Ema::new(short_len),
82 abs_long: Ema::new(long_len),
83 abs_short: Ema::new(short_len),
84 signal_avg: Ema::new(sig_len),
85 extreme_window: ExtremeWindow::new(lookback_extreme),
86 prev_tsi_line: None,
87 prev_signal: None,
88 bars_seen: 0,
89 ctx_mom_long: Ema::new(ctx_long_len),
90 ctx_mom_short: Ema::new(ctx_short_len),
91 ctx_abs_long: Ema::new(ctx_long_len),
92 ctx_abs_short: Ema::new(ctx_short_len),
93 divergence: SlopeDivergence::new(div_len, div_min),
94 alerts: TsiAlerts::default(),
95 }
96 }
97
98 pub fn with_defaults() -> Self {
99 Self::new(25, 13, 7, 0.0, 25.0, -25.0, 5, true, 50, 25, 4, 5.0)
100 }
101}
102
103impl Indicator for Tsi {
104 fn name(&self) -> &str {
105 "tsi"
106 }
107
108 fn warmup_period(&self) -> usize {
109 1 + self.div_len
110 }
111
112 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
113 self.alerts = TsiAlerts::default();
114 self.bars_seen += 1;
115
116 let close = bar.close;
117 let prev_close = match self.prev_close {
118 None => {
119 self.prev_close = Some(close);
120 return None;
121 }
122 Some(p) => p,
123 };
124 self.prev_close = Some(close);
125
126 let mom = close - prev_close;
127 let double_mom = self.mom_short.update(self.mom_long.update(mom)?)?;
128 let double_abs = self.abs_short.update(self.abs_long.update(mom.abs())?)?;
129 let tsi_line = if double_abs != 0.0 {
130 100.0 * double_mom / double_abs
131 } else {
132 0.0
133 };
134 let signal = self.signal_avg.update(tsi_line)?;
135
136 let ctx_double_mom = self.ctx_mom_short.update(self.ctx_mom_long.update(mom)?)?;
137 let ctx_double_abs = self
138 .ctx_abs_short
139 .update(self.ctx_abs_long.update(mom.abs())?)?;
140 let ctx_line = if ctx_double_abs != 0.0 {
141 100.0 * ctx_double_mom / ctx_double_abs
142 } else {
143 0.0
144 };
145
146 let extreme = self.extreme_window.push(tsi_line);
147 let was_oversold = extreme
148 .map(|(low, _)| low <= self.oversold)
149 .unwrap_or(false);
150 let was_overbought = extreme
151 .map(|(_, high)| high >= self.overbought)
152 .unwrap_or(false);
153
154 if let (Some(prev_tsi), Some(prev_sig)) = (self.prev_tsi_line, self.prev_signal) {
155 let bull_cross = crossed_over(prev_tsi, prev_sig, tsi_line, signal);
156 let bear_cross = crossed_under(prev_tsi, prev_sig, tsi_line, signal);
157 self.alerts.bull_extreme = bull_cross && (!self.require_extreme_zone || was_oversold);
158 self.alerts.bear_extreme = bear_cross && (!self.require_extreme_zone || was_overbought);
159 self.alerts.bull_mid_cross =
160 crossed_over(prev_tsi, self.mid_line, tsi_line, self.mid_line);
161 self.alerts.bear_mid_cross =
162 crossed_under(prev_tsi, self.mid_line, tsi_line, self.mid_line);
163
164 self.alerts.extreme_strength = if self.alerts.bull_extreme {
165 let (lowest, _) = extreme.unwrap_or((self.oversold, self.overbought));
166 ((self.oversold - lowest) / self.oversold.abs()).clamp(0.0, 1.0)
167 } else if self.alerts.bear_extreme {
168 let (_, highest) = extreme.unwrap_or((self.oversold, self.overbought));
169 ((highest - self.overbought) / self.overbought.abs()).clamp(0.0, 1.0)
170 } else {
171 0.0
172 };
173 }
174 self.prev_tsi_line = Some(tsi_line);
175 self.prev_signal = Some(signal);
176
177 let div = self.divergence.update(tsi_line, ctx_line);
178 self.alerts.bull_divergence = div.bull;
179 self.alerts.bear_divergence = div.bear;
180 self.alerts.divergence_strength = if div.bull || div.bear {
181 ((div.fast_dir.abs() - self.divergence.div_min()) / self.divergence.div_min())
182 .clamp(0.0, 1.0)
183 } else {
184 0.0
185 };
186
187 let mut extra = HashMap::new();
188 extra.insert("signal".to_string(), signal);
189 extra.insert("ctx".to_string(), ctx_line);
190
191 Some(IndicatorOutput::with_extra(tsi_line, extra))
192 }
193
194 fn reset(&mut self) {
195 self.prev_close = None;
196 self.mom_long.reset();
197 self.mom_short.reset();
198 self.abs_long.reset();
199 self.abs_short.reset();
200 self.signal_avg.reset();
201 self.extreme_window.reset();
202 self.prev_tsi_line = None;
203 self.prev_signal = None;
204 self.bars_seen = 0;
205 self.ctx_mom_long.reset();
206 self.ctx_mom_short.reset();
207 self.ctx_abs_long.reset();
208 self.ctx_abs_short.reset();
209 self.divergence.reset();
210 self.alerts = TsiAlerts::default();
211 }
212
213 fn alerts(&self) -> Vec<IndicatorAlert> {
214 let a = self.alerts;
215 let mut out = Vec::new();
216 if a.bull_extreme {
217 out.push(IndicatorAlert {
218 kind: "bull_extreme".to_string(),
219 note: "TSI · BULL CROSS OVERSOLD".to_string(),
220 strength: a.extreme_strength,
221 });
222 }
223 if a.bear_extreme {
224 out.push(IndicatorAlert {
225 kind: "bear_extreme".to_string(),
226 note: "TSI · BEAR CROSS OVERBOUGHT".to_string(),
227 strength: a.extreme_strength,
228 });
229 }
230 if a.bull_mid_cross {
231 out.push(IndicatorAlert {
232 kind: "bull_mid_cross".to_string(),
233 note: "TSI · CROSS ABOVE ZERO".to_string(),
234 strength: 1.0,
235 });
236 }
237 if a.bear_mid_cross {
238 out.push(IndicatorAlert {
239 kind: "bear_mid_cross".to_string(),
240 note: "TSI · CROSS BELOW ZERO".to_string(),
241 strength: 1.0,
242 });
243 }
244 if a.bull_divergence {
245 out.push(IndicatorAlert {
246 kind: "bull_divergence".to_string(),
247 note: "TSI · BULL DIVERGENCE".to_string(),
248 strength: a.divergence_strength,
249 });
250 }
251 if a.bear_divergence {
252 out.push(IndicatorAlert {
253 kind: "bear_divergence".to_string(),
254 note: "TSI · BEAR DIVERGENCE".to_string(),
255 strength: a.divergence_strength,
256 });
257 }
258 out
259 }
260}