Skip to main content

egui_charts/studies/builtin/
technical_ratings.rs

1//! Technical Ratings.
2//!
3//! Votes of moving averages and oscillators, averaged into a moving-average score, an
4//! oscillator score and a summary score between -1 (all sell) and +1 (all buy), each with the
5//! share of its components that had a vote.
6//!
7//! # Rule
8//!
9//! Every enabled component votes +1 (buy), -1 (sell) or 0 (neutral) on a bar:
10//!
11//! ```text
12//! Moving average (SMA and EMA of three lengths):  sign(close - average)
13//! RSI:                  +1 below 30, -1 above 70
14//! Stochastic %K:        +1 below 20, -1 above 80
15//! CCI:                  +1 below -100, -1 above 100
16//! MACD:                 sign(MACD line - signal line)
17//! Momentum:             sign(momentum)
18//! ```
19//!
20//! Each component counts once, whatever its group. A group's score is the mean of the votes it
21//! has, the summary is the mean of all votes, and a score without a single vote has no value.
22//! A component without a value, in its warm-up (for the MACD the first max(slow, signal) bars)
23//! or on a bar with a non-finite high, low or close, has no vote: it leaves the mean and lowers
24//! the coverage, the votes it has over the components enabled. A rating is derived from a score:
25//!
26//! ```text
27//! Strong Buy   score >= 0.5
28//! Buy          0.1 <= score < 0.5
29//! Neutral      -0.1 < score < 0.1
30//! Sell         -0.5 < score <= -0.1
31//! Strong Sell  score <= -0.5
32//! ```
33//!
34//! The six outputs are the moving-average, oscillator and summary scores followed by their
35//! coverages; the coverages are hidden by default. The table shows the latest bar's score, rating
36//! and coverage of each group.
37
38use crate::model::Bar;
39use crate::studies::palette;
40use crate::studies::{
41    CCI, EMA, Indicator, IndicatorValue, IndicatorValues, MACD, Momentum, RSI, SMA, Stochastic,
42    StudyCell, StudyTable,
43};
44use egui::Color32;
45
46/// Group of a component, in the order of the score outputs.
47#[derive(Clone, Copy, PartialEq)]
48enum Group {
49    MovingAverage,
50    Oscillator,
51}
52
53#[derive(Clone, Copy)]
54enum Rule {
55    Average,
56    Rsi,
57    Stochastic,
58    Cci,
59    Macd,
60    Momentum,
61}
62
63impl Rule {
64    /// The vote of a component with `values` at clean bar `at`, or `None` without one.
65    fn vote(self, values: &IndicatorValues, at: usize, close: f64) -> Option<f64> {
66        let signed = |difference: f64| {
67            if difference > 0.0 {
68                1.0
69            } else if difference < 0.0 {
70                -1.0
71            } else {
72                0.0
73            }
74        };
75        let level = |low: f64, high: f64| {
76            let value = values.value(0, at)?;
77            Some(if value < low {
78                1.0
79            } else if value > high {
80                -1.0
81            } else {
82                0.0
83            })
84        };
85        match self {
86            Self::Average => Some(signed(close - values.value(0, at)?)),
87            Self::Rsi => level(30.0, 70.0),
88            Self::Stochastic => level(20.0, 80.0),
89            Self::Cci => level(-100.0, 100.0),
90            Self::Macd => Some(signed(values.value(0, at)? - values.value(1, at)?)),
91            Self::Momentum => Some(signed(values.value(0, at)?)),
92        }
93    }
94}
95
96#[derive(Clone)]
97struct Component {
98    group: Group,
99    rule: Rule,
100    /// Usable bars before the first vote.
101    warmup: usize,
102    study: Box<dyn Indicator>,
103}
104
105/// Moving-average, oscillator and summary ratings from the votes of their components.
106#[derive(Clone)]
107pub struct TechnicalRatings {
108    fast: usize,
109    medium: usize,
110    slow: usize,
111    simple: bool,
112    exponential: bool,
113    rsi: bool,
114    rsi_period: usize,
115    stochastic: bool,
116    stochastic_period: usize,
117    cci: bool,
118    cci_period: usize,
119    macd: bool,
120    macd_fast: usize,
121    macd_slow: usize,
122    macd_signal: usize,
123    momentum: bool,
124    momentum_period: usize,
125    components: Option<Vec<Component>>,
126    /// The bars with a finite high, low and close, which every component reads, and their
127    /// positions in the input.
128    bars: Vec<Bar>,
129    sources: Vec<usize>,
130    values: IndicatorValues,
131    table: StudyTable,
132    colors: Vec<Color32>,
133    visible: bool,
134}
135
136impl TechnicalRatings {
137    /// Moving averages of the three lengths, each raised to at least 1, and the conventional
138    /// oscillators.
139    pub fn new(fast: usize, medium: usize, slow: usize) -> Self {
140        Self {
141            fast: fast.max(1),
142            medium: medium.max(1),
143            slow: slow.max(1),
144            simple: true,
145            exponential: true,
146            rsi: true,
147            rsi_period: 14,
148            stochastic: true,
149            stochastic_period: 14,
150            cci: true,
151            cci_period: 20,
152            macd: true,
153            macd_fast: 12,
154            macd_slow: 26,
155            macd_signal: 9,
156            momentum: true,
157            momentum_period: 10,
158            components: None,
159            bars: Vec::new(),
160            sources: Vec::new(),
161            values: IndicatorValues::new(),
162            table: StudyTable {
163                title: "Technical Ratings".into(),
164                columns: ["Group", "Score", "Rating", "Coverage"]
165                    .map(String::from)
166                    .to_vec(),
167                rows: Vec::new(),
168            },
169            colors: vec![
170                palette::DISABLED,
171                palette::DISABLED,
172                palette::DISABLED,
173                palette::BLUE,
174                palette::BLUE,
175                palette::BLUE,
176                palette::BRIGHT_RED,
177                palette::BRIGHT_RED,
178                palette::BRIGHT_RED,
179            ],
180            visible: true,
181        }
182    }
183
184    fn build(&self) -> Vec<Component> {
185        let mut components = Vec::new();
186        let mut add = |group, rule, warmup, study: Box<dyn Indicator>| {
187            components.push(Component {
188                group,
189                rule,
190                warmup,
191                study,
192            });
193        };
194        for length in [self.fast, self.medium, self.slow] {
195            if self.simple {
196                add(
197                    Group::MovingAverage,
198                    Rule::Average,
199                    0,
200                    Box::new(SMA::new(length)),
201                );
202            }
203            if self.exponential {
204                add(
205                    Group::MovingAverage,
206                    Rule::Average,
207                    0,
208                    Box::new(EMA::new(length)),
209                );
210            }
211        }
212        let oscillators: [(bool, Rule, usize, Box<dyn Indicator>); 5] = [
213            (self.rsi, Rule::Rsi, 0, Box::new(RSI::new(self.rsi_period))),
214            (
215                self.stochastic,
216                Rule::Stochastic,
217                0,
218                Box::new(Stochastic::new(self.stochastic_period, 3, 3)),
219            ),
220            (self.cci, Rule::Cci, 0, Box::new(CCI::new(self.cci_period))),
221            // The MACD shows no value before its slow and signal averages are seeded, then
222            // shows all of its history; the vote starts at that bar so that it never changes.
223            (
224                self.macd,
225                Rule::Macd,
226                self.macd_slow.max(self.macd_signal) - 1,
227                Box::new(MACD::new(self.macd_fast, self.macd_slow, self.macd_signal)),
228            ),
229            (
230                self.momentum,
231                Rule::Momentum,
232                0,
233                Box::new(Momentum::new(self.momentum_period)),
234            ),
235        ];
236        for (enabled, rule, warmup, study) in oscillators {
237            if enabled {
238                add(Group::Oscillator, rule, warmup, study);
239            }
240        }
241        components
242    }
243
244    /// The scores and coverages on clean bar `at`.
245    fn row(components: &[Component], at: usize, close: f64) -> [f64; 6] {
246        // Votes and enabled components of the moving averages, the oscillators and in total.
247        let mut sum = [0.0; 2];
248        let mut votes = [0usize; 2];
249        let mut enabled = [0usize; 2];
250        for component in components {
251            let group = usize::from(component.group == Group::Oscillator);
252            enabled[group] += 1;
253            if let Some(vote) = (at >= component.warmup)
254                .then(|| component.rule.vote(component.study.values(), at, close))
255                .flatten()
256            {
257                sum[group] += vote;
258                votes[group] += 1;
259            }
260        }
261        let score = |sum: f64, votes: usize| {
262            if votes == 0 {
263                f64::NAN
264            } else {
265                sum / votes as f64
266            }
267        };
268        let coverage = |votes: usize, enabled: usize| {
269            if enabled == 0 {
270                f64::NAN
271            } else {
272                votes as f64 / enabled as f64
273            }
274        };
275        [
276            score(sum[0], votes[0]),
277            score(sum[1], votes[1]),
278            score(sum[0] + sum[1], votes[0] + votes[1]),
279            coverage(votes[0], enabled[0]),
280            coverage(votes[1], enabled[1]),
281            coverage(votes[0] + votes[1], enabled[0] + enabled[1]),
282        ]
283    }
284}
285
286/// Construct with the conventional default parameters.
287impl Default for TechnicalRatings {
288    fn default() -> Self {
289        Self::new(10, 50, 200)
290    }
291}
292
293/// The displayed rating of a score between -1 and 1.
294fn rating(score: f64) -> &'static str {
295    if score >= 0.5 {
296        "Strong Buy"
297    } else if score >= 0.1 {
298        "Buy"
299    } else if score > -0.1 {
300        "Neutral"
301    } else if score > -0.5 {
302        "Sell"
303    } else {
304        "Strong Sell"
305    }
306}
307
308impl Indicator for TechnicalRatings {
309    crate::studies::inputs::study_inputs!(self;
310        fast: length("Fast moving average", 1, usize::MAX),
311        medium: length("Medium moving average", 1, usize::MAX),
312        slow: length("Slow moving average", 1, usize::MAX),
313        simple: toggle("Simple moving averages"),
314        exponential: toggle("Exponential moving averages"),
315        rsi: toggle("RSI"),
316        rsi_period: length("RSI period", 1, usize::MAX),
317        stochastic: toggle("Stochastic"),
318        stochastic_period: length("Stochastic period", 1, usize::MAX),
319        cci: toggle("CCI"),
320        cci_period: length("CCI period", 1, usize::MAX),
321        macd: toggle("MACD"),
322        macd_fast: length("MACD fast period", 1, usize::MAX),
323        macd_slow: length("MACD slow period", 1, usize::MAX),
324        macd_signal: length("MACD signal period", 1, usize::MAX),
325        momentum: toggle("Momentum"),
326        momentum_period: length("Momentum period", 1, usize::MAX)
327    ; {
328        self.components = None;
329    });
330
331    fn name(&self) -> &str {
332        "Technical Ratings"
333    }
334
335    fn desc(&self) -> &str {
336        "Technical Ratings - Buy and sell votes of moving averages and oscillators, scored from -1 to 1"
337    }
338
339    fn calculate(&mut self, data: &[Bar]) {
340        self.update(data, 0);
341    }
342
343    fn update(&mut self, data: &[Bar], from: usize) {
344        let mut from = from.min(self.values.len()).min(data.len());
345        if self.components.is_none() {
346            self.components = Some(self.build());
347            from = 0;
348        }
349        let components = self.components.as_mut().expect("components built");
350        let kept = self.sources.partition_point(|&source| source < from);
351        self.sources.truncate(kept);
352        self.bars.truncate(kept);
353        let usable =
354            |bar: &Bar| bar.high.is_finite() && bar.low.is_finite() && bar.close.is_finite();
355        for (source, bar) in data.iter().enumerate().skip(from) {
356            if usable(bar) {
357                self.sources.push(source);
358                self.bars.push(bar.clone());
359            }
360        }
361        for component in components.iter_mut() {
362            component.study.update(&self.bars, kept);
363        }
364        self.values.truncate(from);
365        let mut at = kept;
366        for bar in &data[from..] {
367            if usable(bar) {
368                self.values
369                    .push_lines(&Self::row(components, at, bar.close));
370                at += 1;
371            } else {
372                self.values.push(IndicatorValue::None);
373            }
374        }
375        let latest = self.values.len().checked_sub(1);
376        self.table.rows = ["Moving averages", "Oscillators", "Summary"]
377            .into_iter()
378            .enumerate()
379            .map(|(group, label)| {
380                let at = |line: usize| latest.and_then(|bar| self.values.value(line, bar));
381                let (score, coverage) = (at(group), at(group + 3));
382                vec![
383                    StudyCell::Text(label.into()),
384                    StudyCell::Text(score.map_or("N/A".into(), |score| format!("{score:+.2}"))),
385                    StudyCell::Text(score.map_or("N/A", rating).into()),
386                    StudyCell::Text(
387                        coverage
388                            .map_or("N/A".into(), |coverage| format!("{:.0}%", coverage * 100.0)),
389                    ),
390                ]
391            })
392            .collect();
393    }
394
395    fn is_incremental(&self) -> bool {
396        true
397    }
398
399    fn table(&self) -> Option<&StudyTable> {
400        Some(&self.table)
401    }
402
403    fn plot_style(&self, line: usize) -> crate::studies::PlotStyle {
404        crate::studies::PlotStyle {
405            color: self.colors[line % 3],
406            width: 1.0,
407            pattern: crate::model::LineStyle::Solid,
408            plot_type: crate::studies::PlotType::Columns,
409            visible: line < 3,
410        }
411    }
412
413    fn values(&self) -> &IndicatorValues {
414        &self.values
415    }
416
417    fn colors(&self) -> Vec<Color32> {
418        self.colors.clone()
419    }
420
421    fn set_colors(&mut self, colors: Vec<Color32>) {
422        if colors.len() < 9 {
423            for (line, color) in colors.into_iter().take(3).enumerate() {
424                self.set_color(line, color);
425            }
426        } else {
427            for (old, new) in self.colors.iter_mut().zip(colors) {
428                *old = new;
429            }
430        }
431    }
432
433    fn set_color(&mut self, line: usize, color: Color32) {
434        if line < 3 {
435            for slot in [line, line + 3, line + 6] {
436                self.colors[slot] = color;
437            }
438        }
439    }
440
441    fn plot_color(&self, line: usize, bar: usize) -> Option<Color32> {
442        if line >= 3 {
443            return None;
444        }
445        let value = self.values.value(line, bar)?;
446        let neutral = self.colors[line];
447        let endpoint = self.colors[line + if value >= 0.0 { 3 } else { 6 }];
448        if neutral == endpoint {
449            return Some(neutral);
450        }
451        let color = neutral.lerp_to_gamma(endpoint, (value.abs() / 0.2).min(1.0) as f32);
452        let mut previous = value;
453        let mut streak = 0;
454        for at in (0..bar).rev().take(5) {
455            let Some(earlier) = self.values.value(line, at) else {
456                break;
457            };
458            if (value > 0.0 && previous > earlier) || (value < 0.0 && previous < earlier) {
459                streak += 1;
460                previous = earlier;
461            } else {
462                break;
463            }
464        }
465        Some(color.gamma_multiply_u8(((0.5 + streak as f32 * 0.1) * 255.0).round() as u8))
466    }
467
468    fn pane(&self) -> crate::studies::StudyPane {
469        crate::studies::StudyPane {
470            height: None,
471            range: Some((-1.1, 1.1)),
472            levels: vec![
473                (
474                    0.5,
475                    Color32::from_rgba_unmultiplied(91, 156, 246, 128),
476                    "Strong Buy",
477                ),
478                (
479                    -0.5,
480                    Color32::from_rgba_unmultiplied(239, 154, 154, 128),
481                    "Strong Sell",
482                ),
483            ],
484            fill_between_levels: false,
485        }
486    }
487
488    fn is_overlay(&self) -> bool {
489        false
490    }
491
492    fn is_visible(&self) -> bool {
493        self.visible
494    }
495
496    fn set_visible(&mut self, visible: bool) {
497        self.visible = visible;
498    }
499
500    fn clone_box(&self) -> Box<dyn Indicator> {
501        Box::new(self.clone())
502    }
503
504    fn line_names(&self) -> Vec<String> {
505        [
506            "Moving Averages",
507            "Oscillators",
508            "Summary",
509            "Moving Averages Coverage",
510            "Oscillators Coverage",
511            "Summary Coverage",
512        ]
513        .map(String::from)
514        .to_vec()
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::studies::InputValue;
522    use chrono::{DateTime, Duration};
523
524    /// Scores, coverages and ratings of two moving-average lengths and momentum, by hand.
525    ///
526    /// Closes 10, 12 and 11.4. SMA(2) is 11 and 11.7 on the last two bars; EMA(2) seeds with 10 and
527    /// moves 2/3 of the way: 34/3 and 34/9 + 22.8/3 = 11.3778. Momentum(1) is +2 and -0.6.
528    #[test]
529    fn scores_coverage_and_ratings_follow_the_votes() {
530        let bars: Vec<_> = [10.0, 12.0, 11.4]
531            .into_iter()
532            .enumerate()
533            .map(|(i, c)| {
534                Bar::new(
535                    DateTime::UNIX_EPOCH + Duration::days(i as i64),
536                    c,
537                    c,
538                    c,
539                    c,
540                    1.0,
541                )
542            })
543            .collect();
544        let mut study = TechnicalRatings::default();
545        for (id, value) in [
546            ("fast", InputValue::Length(2)),
547            ("medium", InputValue::Length(2)),
548            ("slow", InputValue::Length(2)),
549            ("momentum_period", InputValue::Length(1)),
550            ("rsi", InputValue::Toggle(false)),
551            ("stochastic", InputValue::Toggle(false)),
552            ("cci", InputValue::Toggle(false)),
553            ("macd", InputValue::Toggle(false)),
554        ] {
555            study.set_input(id, value).unwrap();
556        }
557        study.calculate(&bars);
558        // Bar 0: no SMA or momentum yet; the three EMAs equal the close and vote neutral.
559        // Bar 1: all seven components buy. Bar 2: SMAs sell, EMAs buy, momentum sells.
560        let expected = [
561            [0.0, f64::NAN, 0.0, 0.5, 0.0, 3.0 / 7.0],
562            [1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
563            [0.0, -1.0, -1.0 / 7.0, 1.0, 1.0, 1.0],
564        ];
565        for (bar, row) in expected.iter().enumerate() {
566            for (line, want) in row.iter().enumerate() {
567                match study.values().value(line, bar) {
568                    Some(got) => assert!((got - want).abs() < 1e-12, "line {line} bar {bar}"),
569                    None => assert!(want.is_nan(), "line {line} bar {bar}"),
570                }
571            }
572        }
573        let rows: Vec<Vec<_>> = study
574            .table()
575            .unwrap()
576            .rows
577            .iter()
578            .map(|row| match &row[..] {
579                [
580                    _,
581                    StudyCell::Text(score),
582                    StudyCell::Text(rating),
583                    StudyCell::Text(coverage),
584                ] => {
585                    vec![score.clone(), rating.clone(), coverage.clone()]
586                }
587                _ => panic!("text cells"),
588            })
589            .collect();
590        assert_eq!(
591            rows,
592            [
593                ["+0.00", "Neutral", "100%"],
594                ["-1.00", "Strong Sell", "100%"],
595                ["-0.14", "Sell", "100%"],
596            ]
597        );
598        for (score, label) in [
599            (0.5, "Strong Buy"),
600            (0.1, "Buy"),
601            (0.09, "Neutral"),
602            (-0.09, "Neutral"),
603            (-0.1, "Sell"),
604            (-0.49, "Sell"),
605            (-0.5, "Strong Sell"),
606        ] {
607            assert_eq!(rating(score), label, "{score}");
608        }
609    }
610}