Skip to main content

kestrel_chartkit/indicator/
tsi.rs

1use 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
9/// True Strength Index with a signal and a context line.
10///
11/// With `m = close - prev_close`,
12/// `TSI = 100 * Ema(short)(Ema(long)(m)) / Ema(short)(Ema(long)(|m|))`, `0` for a zero denominator;
13/// all averages are the shared [`Ema`] with its first-sample seed, running from the first change.
14/// `extra["signal"]` is `Ema(sig_len)` over the TSI; `extra["ctx"]` the same construction over the
15/// context lengths.
16///
17/// First output: with the second bar. [`Indicator::reset`] clears all averages.
18pub 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}