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
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}