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