Skip to main content

egui_charts/studies/builtin/
trend_intensity.rs

1use super::over_finite_bars;
2use crate::model::Bar;
3use crate::studies::palette;
4use crate::studies::stepper::{update_window, usable_behind};
5use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
6use egui::Color32;
7
8/// Trend Intensity Index (TII), after M. H. Pee (Technical Analysis of Stocks & Commodities, June 2002).
9///
10/// Over the last `period` bars, the share of the deviations of the close from its `2 * period` bar simple moving average that lies above it, weighted by size: `100 * SD+ / (SD+ + SD-)`.
11/// Values above 50 indicate an uptrend, below 50 a downtrend.
12/// The first value is on bar `3 * period - 2`; when every deviation is zero the value is 50.
13#[derive(Clone)]
14pub struct TrendIntensityIndex {
15    /// Number of deviations summed; the moving average spans twice as many bars.
16    period: usize,
17    values: IndicatorValues,
18    color: Color32,
19    visible: bool,
20}
21
22impl TrendIntensityIndex {
23    /// Create with a lookback of `period` bars, clamped to at least 1.
24    pub fn new(period: usize) -> Self {
25        Self {
26            period: period.max(1),
27            values: IndicatorValues::new(),
28            color: palette::BLUE, // Indigo/Blue
29            visible: true,
30        }
31    }
32
33    /// Set the plot color.
34    pub fn with_color(mut self, color: Color32) -> Self {
35        self.color = color;
36        self
37    }
38}
39
40impl Default for TrendIntensityIndex {
41    fn default() -> Self {
42        Self::new(30)
43    }
44}
45
46impl TrendIntensityIndex {
47    /// The values over `data`, every bar of which has finite inputs.
48    fn compute(&self, data: &[Bar]) -> IndicatorValues {
49        let mut values = IndicatorValues::new();
50        let n = self.period;
51        let ma_len = n.saturating_mul(2);
52        let first = ma_len.saturating_add(n) - 2;
53        // Deviation of each close from the moving average ending on its bar, once it exists.
54        let dev: Vec<f64> = data
55            .iter()
56            .enumerate()
57            .map(|(i, bar)| {
58                if i + 1 < ma_len {
59                    return 0.0;
60                }
61                // Summing the differences keeps a flat window's deviation exactly zero.
62                let window = &data[i + 1 - ma_len..=i];
63                window.iter().map(|b| bar.close - b.close).sum::<f64>() / ma_len as f64
64            })
65            .collect();
66        for i in 0..data.len() {
67            if i < first {
68                values.push(IndicatorValue::None);
69                continue;
70            }
71            let (mut up, mut down) = (0.0, 0.0);
72            for &d in &dev[i + 1 - n..=i] {
73                if d > 0.0 {
74                    up += d;
75                } else {
76                    down -= d;
77                }
78            }
79            let total = up + down;
80            let tii = if total > 0.0 {
81                100.0 * up / total
82            } else {
83                50.0
84            };
85            values.push(IndicatorValue::Single(tii));
86        }
87
88        values
89    }
90}
91
92impl Indicator for TrendIntensityIndex {
93    fn source(&self) -> Option<crate::studies::StudySource> {
94        Some(crate::studies::StudySource::Price(
95            crate::model::PriceSource::Close,
96        ))
97    }
98
99    crate::studies::inputs::study_inputs!(self;
100        period: length("Period", 1, usize::MAX)
101    ; {});
102
103    fn name(&self) -> &str {
104        "TII"
105    }
106
107    fn desc(&self) -> &str {
108        "Trend Intensity Index - Measures trend strength"
109    }
110
111    fn calculate(&mut self, data: &[Bar]) {
112        self.update(data, 0);
113    }
114
115    fn update(&mut self, data: &[Bar], from: usize) {
116        let mut values = std::mem::take(&mut self.values);
117        update_window(
118            &mut values,
119            data,
120            from,
121            usable_behind(
122                self.period
123                    .saturating_mul(2)
124                    .saturating_add(self.period)
125                    .saturating_sub(2),
126                |b| b.close.is_finite(),
127            ),
128            |data| over_finite_bars(data, |b| b.close.is_finite(), |data| self.compute(data)),
129        );
130        self.values = values;
131    }
132
133    fn is_incremental(&self) -> bool {
134        true
135    }
136
137    fn values(&self) -> &IndicatorValues {
138        &self.values
139    }
140
141    fn colors(&self) -> Vec<Color32> {
142        vec![self.color]
143    }
144
145    fn set_colors(&mut self, colors: Vec<Color32>) {
146        if !colors.is_empty() {
147            self.color = colors[0];
148        }
149    }
150
151    fn is_overlay(&self) -> bool {
152        false
153    }
154
155    fn is_visible(&self) -> bool {
156        self.visible
157    }
158
159    fn set_visible(&mut self, visible: bool) {
160        self.visible = visible;
161    }
162
163    fn clone_box(&self) -> Box<dyn Indicator> {
164        Box::new(self.clone())
165    }
166
167    fn line_names(&self) -> Vec<String> {
168        vec![format!("TII({})", self.period)]
169    }
170}
171
172/// Qstick (Quantitative Candlestick)
173/// Moving avg of the open-close difference
174/// Positive = bullish, Negative = bearish
175#[derive(Clone)]
176pub struct Qstick {
177    period: usize,
178    values: IndicatorValues,
179    color: Color32,
180    visible: bool,
181}
182
183impl Qstick {
184    /// Create with a lookback of `period` bars, clamped to at least 1.
185    pub fn new(period: usize) -> Self {
186        Self {
187            period: period.max(1),
188            values: IndicatorValues::new(),
189            color: palette::PURPLE,
190            visible: true,
191        }
192    }
193
194    /// Set the plot color.
195    pub fn with_color(mut self, color: Color32) -> Self {
196        self.color = color;
197        self
198    }
199}
200
201impl Default for Qstick {
202    fn default() -> Self {
203        Self::new(8)
204    }
205}
206
207impl Qstick {
208    fn compute(&self, data: &[Bar]) -> IndicatorValues {
209        let mut values = IndicatorValues::new();
210
211        if data.len() < self.period {
212            for _ in 0..data.len() {
213                values.push(IndicatorValue::None);
214            }
215            return values;
216        }
217
218        for i in 0..data.len() {
219            if i < self.period - 1 {
220                values.push(IndicatorValue::None);
221            } else {
222                let window = &data[i + 1 - self.period..=i];
223
224                // Calculate avg of (close - open)
225                let avg: f64 =
226                    window.iter().map(|b| b.close - b.open).sum::<f64>() / self.period as f64;
227
228                values.push(IndicatorValue::Single(avg));
229            }
230        }
231        values
232    }
233}
234
235impl Indicator for Qstick {
236    fn value_format(&self) -> crate::scales::StudyFormat {
237        crate::scales::StudyFormat::Price
238    }
239
240    crate::studies::inputs::study_inputs!(self;
241        period: length("Period", 1, usize::MAX)
242    ; {});
243
244    fn name(&self) -> &str {
245        "Qstick"
246    }
247
248    fn desc(&self) -> &str {
249        "Qstick - Moving avg of candlestick body"
250    }
251
252    fn calculate(&mut self, data: &[Bar]) {
253        self.update(data, 0);
254    }
255
256    fn update(&mut self, data: &[Bar], from: usize) {
257        let mut values = std::mem::take(&mut self.values);
258        update_window(
259            &mut values,
260            data,
261            from,
262            |_data, from| from.saturating_sub(self.period - 1),
263            |data| self.compute(data),
264        );
265        self.values = values;
266    }
267
268    fn is_incremental(&self) -> bool {
269        true
270    }
271
272    fn values(&self) -> &IndicatorValues {
273        &self.values
274    }
275
276    fn colors(&self) -> Vec<Color32> {
277        vec![self.color]
278    }
279
280    fn set_colors(&mut self, colors: Vec<Color32>) {
281        if !colors.is_empty() {
282            self.color = colors[0];
283        }
284    }
285
286    fn is_overlay(&self) -> bool {
287        false
288    }
289
290    fn is_visible(&self) -> bool {
291        self.visible
292    }
293
294    fn set_visible(&mut self, visible: bool) {
295        self.visible = visible;
296    }
297
298    fn clone_box(&self) -> Box<dyn Indicator> {
299        Box::new(self.clone())
300    }
301
302    fn line_names(&self) -> Vec<String> {
303        vec![format!("Qstick({})", self.period)]
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use chrono::Utc;
311
312    fn make_bar(open: f64, close: f64) -> Bar {
313        let high = open.max(close) + 1.0;
314        let low = open.min(close) - 1.0;
315        Bar {
316            time: Utc::now(),
317            open,
318            high,
319            low,
320            close,
321            volume: 1000.0,
322            colors: None,
323            payload: None,
324        }
325    }
326
327    /// Expected values follow M. H. Pee, "Trend Intensity Index", Technical Analysis of Stocks & Commodities, June 2002, computed independently in exact arithmetic.
328    #[test]
329    fn tii_matches_published_definition() {
330        let rising: Vec<f64> = (0..90).map(|i| 100.0 + i as f64).collect();
331        let falling: Vec<f64> = rising.iter().rev().copied().collect();
332        let mixed = [
333            10.0, 12.0, 11.0, 14.0, 13.0, 15.0, 12.0, 16.0, 14.0, 13.0, 17.0, 15.0,
334        ];
335        // (period, closes, first bar with a value, values from that bar on)
336        let cases: [(usize, &[f64], usize, &[f64]); 4] = [
337            (30, &rising, 88, &[100.0, 100.0]),
338            (30, &falling, 88, &[0.0, 0.0]),
339            (3, &[0.1; 9], 7, &[50.0, 50.0]),
340            (
341                3,
342                &mixed,
343                7,
344                &[600.0 / 7.0, 75.0, 75.0, 75.0, 1800.0 / 23.0],
345            ),
346        ];
347        for (period, closes, first, expected) in cases {
348            let data: Vec<Bar> = closes.iter().map(|&c| make_bar(c, c)).collect();
349            let mut tii = TrendIntensityIndex::new(period);
350            tii.calculate(&data);
351            let got: Vec<Option<f64>> = tii
352                .values()
353                .to_rows()
354                .iter()
355                .map(|v| match v {
356                    IndicatorValue::Single(x) => Some(*x),
357                    _ => None,
358                })
359                .collect();
360            assert_eq!(got.len(), closes.len());
361            assert!(got[..first].iter().all(Option::is_none), "{got:?}");
362            for (i, want) in expected.iter().enumerate() {
363                let v = got[first + i].expect("value after warm-up");
364                assert!((v - want).abs() < 1e-12, "bar {}: {v} != {want}", first + i);
365            }
366        }
367    }
368
369    #[test]
370    fn test_qstick() {
371        let mut qs = Qstick::new(5);
372
373        // Bullish candles (close > open)
374        let data: Vec<Bar> = (0..10)
375            .map(|i| make_bar(100.0 + i as f64, 102.0 + i as f64))
376            .collect();
377
378        qs.calculate(&data);
379
380        assert_eq!(qs.values.len(), 10);
381
382        // Should be positive for bullish candles
383        let rows = qs.values.to_rows();
384        let IndicatorValue::Single(v) = rows.last().unwrap() else {
385            panic!("no value: {:?}", qs.values.to_rows().last().unwrap());
386        };
387        assert!(*v > 0.0);
388    }
389}