Skip to main content

egui_charts/studies/builtin/
ma_combination.rs

1//! Moving average combinations and their crossing markers.
2
3use super::{EMA, HMA, SMA, SmoothedMA, VWMA, WMA};
4use crate::model::{Bar, PriceSource};
5use crate::studies::stepper::Stepper;
6use crate::studies::{
7    CrossDirection, Indicator, IndicatorFill, IndicatorLineStyle, IndicatorMarker, IndicatorValue,
8    IndicatorValues, InputKind, StudyInput,
9};
10use egui::Color32;
11
12/// Average formula used by a line of [`MACombination`].
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum AverageType {
15    /// Arithmetic mean of a complete window.
16    Simple,
17    /// Exponential smoothing with alpha 2/(period+1), seeded by the first price.
18    Exponential,
19    /// Linear weights 1 through period, newest heaviest.
20    Weighted,
21    /// Wilder smoothing with alpha 1/period, seeded by a complete SMA.
22    Smoothed,
23    /// Price weighted by volume over a complete window.
24    VolumeWeighted,
25    /// Hull's weighted average of twice the half-length WMA minus the full WMA.
26    Hull,
27}
28
29impl AverageType {
30    fn label(self) -> &'static str {
31        match self {
32            Self::Simple => "SMA",
33            Self::Exponential => "EMA",
34            Self::Weighted => "WMA",
35            Self::Smoothed => "SMMA",
36            Self::VolumeWeighted => "VWMA",
37            Self::Hull => "HMA",
38        }
39    }
40
41    pub(super) fn build(self, period: usize) -> Box<dyn Indicator> {
42        match self {
43            Self::Simple => Box::new(SMA::new(period)),
44            Self::Exponential => Box::new(EMA::new(period)),
45            Self::Weighted => Box::new(WMA::new(period)),
46            Self::Smoothed => Box::new(SmoothedMA::new(period)),
47            Self::VolumeWeighted => Box::new(VWMA::new(period)),
48            Self::Hull => Box::new(HMA::new(period)),
49        }
50    }
51}
52
53/// One moving average's formula, length, input price and colour.
54#[derive(Clone, Debug)]
55pub struct AverageLine {
56    /// Average formula.
57    pub kind: AverageType,
58    /// Window length; zero is treated as one (Hull requires two).
59    pub period: usize,
60    /// Source price; defaults to close.
61    pub source: PriceSource,
62    /// Line colour.
63    pub color: Color32,
64}
65
66impl AverageLine {
67    /// A moving average of closing prices.
68    pub fn new(kind: AverageType, period: usize, color: Color32) -> Self {
69        Self {
70            kind,
71            period: period.max(1),
72            source: PriceSource::Close,
73            color,
74        }
75    }
76
77    /// Select this line's input price independently of the other lines.
78    pub fn with_source(mut self, source: PriceSource) -> Self {
79        self.source = source;
80        self
81    }
82}
83
84/// The most averages a combination holds.
85const MAX_AVERAGES: usize = 14;
86
87/// The average index of a per-average input id such as `period_2`.
88fn average_slot(id: &str) -> Option<usize> {
89    let (field, index) = id.rsplit_once('_')?;
90    if matches!(field, "period" | "kind" | "source") {
91        index.parse().ok()
92    } else {
93        None
94    }
95}
96
97/// Two to fourteen independently configured averages. Two to four form a
98/// combination; longer configurations form a ribbon. Each line keeps its
99/// standalone study's seed and warm-up. A missing line is NaN.
100///
101/// Crossings of lines 0 and 1 are marked by default. A touch does not cross:
102/// the marker appears only when the last nonzero difference changes sign.
103/// A missing value breaks that comparison, so a gap cannot create a signal.
104///
105/// Its inputs hold the number of averages, the crossing pair and the colouring by direction,
106/// then the length, formula and price of each average, so that a template recreates it.
107#[derive(Clone)]
108pub struct MACombination {
109    lines: Vec<AverageLine>,
110    defaults: Vec<AverageLine>,
111    values: IndicatorValues,
112    averages: Vec<Box<dyn Indicator>>,
113    sourced: Vec<Vec<Bar>>,
114    crossing: Stepper<(usize, Option<std::cmp::Ordering>)>,
115    markers: Vec<IndicatorMarker>,
116    /// The two averages whose crossings are marked, numbered from 1; 0 picks none.
117    cross: [usize; 2],
118    default_cross: [usize; 2],
119    pub(super) wait_for_all: bool,
120    /// Whether each line is coloured by its own direction, the slowest wide and filled.
121    directional: bool,
122    default_directional: bool,
123    visible: bool,
124}
125
126impl MACombination {
127    /// Create two to fourteen lines. Returns an error for any other count.
128    pub fn new(lines: Vec<AverageLine>) -> Result<Self, &'static str> {
129        if !(2..=MAX_AVERAGES).contains(&lines.len()) {
130            return Err("a combination needs 2 to 14 averages");
131        }
132        let lines: Vec<_> = lines
133            .into_iter()
134            .map(|mut line| {
135                line.period = line
136                    .period
137                    .max(if line.kind == AverageType::Hull { 2 } else { 1 });
138                line
139            })
140            .collect();
141        Ok(Self {
142            defaults: lines.clone(),
143            lines,
144            values: IndicatorValues::new(),
145            averages: Vec::new(),
146            sourced: Vec::new(),
147            crossing: Stepper::default(),
148            markers: Vec::new(),
149            cross: [1, 2],
150            default_cross: [1, 2],
151            wait_for_all: false,
152            directional: false,
153            default_directional: false,
154            visible: true,
155        })
156    }
157
158    /// Choose the ordered pair to mark. Up means the first crosses above the
159    /// second. Both indices must exist and differ.
160    pub fn with_crossing_pair(mut self, first: usize, second: usize) -> Result<Self, &'static str> {
161        if first == second || first >= self.lines.len() || second >= self.lines.len() {
162            return Err("crossing indices must name two different averages");
163        }
164        self.cross = [first + 1, second + 1];
165        self.default_cross = self.cross;
166        Ok(self)
167    }
168
169    /// Twelve exponential averages of lengths 5, 10, ..., 60, coloured by their
170    /// own direction. The slowest is 3 px with a purple fill fading to the bottom.
171    pub fn ribbon() -> Self {
172        let purple = Color32::from_rgb(98, 55, 174);
173        let mut study = Self::new(
174            (1..=12)
175                .map(|i| AverageLine::new(AverageType::Exponential, i * 5, purple))
176                .collect(),
177        )
178        .unwrap();
179        study.directional = true;
180        study.default_directional = true;
181        study.without_crossings()
182    }
183
184    /// Mark no crossings until a pair is chosen.
185    pub(super) fn without_crossings(mut self) -> Self {
186        self.cross = [0, 0];
187        self.default_cross = [0, 0];
188        self
189    }
190
191    /// A single-hue ribbon, all lines 1 px, without a fill or crossing markers.
192    pub fn muted_ribbon() -> Self {
193        let mut study = Self::ribbon();
194        study.directional = false;
195        study.default_directional = false;
196        for (i, line) in study.lines.iter_mut().enumerate() {
197            line.color = Color32::from_rgb(
198                75 + (44 * i / 11) as u8,
199                13 + (8 * i / 11) as u8,
200                76 + (45 * i / 11) as u8,
201            );
202        }
203        study.defaults = study.lines.clone();
204        study
205    }
206
207    /// The zero-based averages whose crossings are marked: two different picks among the lines.
208    fn crossing_pair(&self) -> Option<(usize, usize)> {
209        let [first, second] = self.cross;
210        let picked = |pick: usize| (1..=self.lines.len()).contains(&pick);
211        if first != second && picked(first) && picked(second) {
212            Some((first - 1, second - 1))
213        } else {
214            None
215        }
216    }
217
218    /// The inputs a preset offers: those of its averages, its crossing pair among them or None,
219    /// and, for a ribbon, its colouring by direction. A preset keeps its number of averages.
220    pub(super) fn preset_inputs(&self, ribbon: bool) -> Vec<StudyInput> {
221        self.inputs()
222            .into_iter()
223            .filter(|input| match input.id.as_str() {
224                "average_count" => false,
225                "directional" => ribbon,
226                _ => true,
227            })
228            .map(|mut input| {
229                if let ("cross_first" | "cross_second", InputKind::Choice(picks)) =
230                    (input.id.as_str(), &mut input.kind)
231                {
232                    picks.truncate(self.lines.len() + 1);
233                }
234                input
235            })
236            .collect()
237    }
238}
239
240impl Indicator for MACombination {
241    fn inputs(&self) -> Vec<crate::studies::StudyInput> {
242        use crate::studies::InputValue;
243        let kinds = [
244            AverageType::Simple,
245            AverageType::Exponential,
246            AverageType::Weighted,
247            AverageType::Smoothed,
248            AverageType::VolumeWeighted,
249            AverageType::Hull,
250        ];
251        let sources = PriceSource::all();
252        let picks: Vec<String> = (0..=MAX_AVERAGES)
253            .map(|pick| {
254                if pick == 0 {
255                    "None".to_owned()
256                } else {
257                    pick.to_string()
258                }
259            })
260            .collect();
261        let mut inputs = vec![
262            StudyInput::new(
263                "average_count",
264                "Averages",
265                InputValue::Length(self.lines.len()),
266                InputKind::Length(2, MAX_AVERAGES),
267            )
268            .with_default(InputValue::Length(self.defaults.len())),
269            StudyInput::new(
270                "directional",
271                "Colour by direction",
272                InputValue::Toggle(self.directional),
273                InputKind::Toggle,
274            )
275            .with_default(InputValue::Toggle(self.default_directional)),
276            StudyInput::new(
277                "cross_first",
278                "Crossing average",
279                InputValue::Choice(self.cross[0]),
280                InputKind::Choice(picks.clone()),
281            )
282            .with_default(InputValue::Choice(self.default_cross[0])),
283            StudyInput::new(
284                "cross_second",
285                "Crossed average",
286                InputValue::Choice(self.cross[1]),
287                InputKind::Choice(picks),
288            )
289            .with_default(InputValue::Choice(self.default_cross[1])),
290        ];
291        inputs.extend(self.lines.iter().enumerate().flat_map(|(index, line)| {
292            // An average added after construction has no construction default; it is its own.
293            let default = self.defaults.get(index).unwrap_or(line);
294            [
295                StudyInput::new(
296                    format!("period_{index}"),
297                    format!("Length {}", index + 1),
298                    InputValue::Length(line.period),
299                    InputKind::Length(
300                        if line.kind == AverageType::Hull { 2 } else { 1 },
301                        usize::MAX,
302                    ),
303                )
304                .with_default(InputValue::Length(default.period)),
305                StudyInput::new(
306                    format!("kind_{index}"),
307                    format!("Average {}", index + 1),
308                    InputValue::Choice(kinds.iter().position(|kind| *kind == line.kind).unwrap()),
309                    InputKind::Choice(kinds.iter().map(|kind| kind.label().to_owned()).collect()),
310                )
311                .with_default(InputValue::Choice(
312                    kinds.iter().position(|kind| *kind == default.kind).unwrap(),
313                )),
314                StudyInput::new(
315                    format!("source_{index}"),
316                    format!("Source {}", index + 1),
317                    InputValue::Choice(
318                        sources
319                            .iter()
320                            .position(|source| *source == line.source)
321                            .unwrap(),
322                    ),
323                    InputKind::Choice(
324                        sources
325                            .iter()
326                            .map(|source| source.label().to_owned())
327                            .collect(),
328                    ),
329                )
330                .with_default(InputValue::Choice(
331                    sources
332                        .iter()
333                        .position(|source| *source == default.source)
334                        .unwrap(),
335                )),
336            ]
337        }));
338        inputs
339    }
340
341    /// `average_count` resizes the averages, so it comes first in an applied set of inputs; an
342    /// input of an average past the last has nothing to edit and is accepted unchanged. Averages
343    /// added take the settings the study was built with at their place, or copy the last one.
344    /// A crossing pick past the averages, or the same average twice, marks no crossings.
345    fn set_input(
346        &mut self,
347        id: &str,
348        value: crate::studies::InputValue,
349    ) -> Result<(), crate::studies::StudyError> {
350        use crate::studies::InputValue;
351        if average_slot(id).is_some_and(|index| (self.lines.len()..MAX_AVERAGES).contains(&index)) {
352            return Ok(());
353        }
354        let schema = self
355            .inputs()
356            .into_iter()
357            .find(|input| input.id == id)
358            .ok_or_else(|| crate::studies::StudyError::UnknownInput(id.into()))?;
359        if !schema.accepts(&value) {
360            return Err(crate::studies::StudyError::invalid_input(id));
361        }
362        match (id, value) {
363            ("average_count", InputValue::Length(count)) => {
364                self.lines.truncate(count);
365                while self.lines.len() < count {
366                    let index = self.lines.len();
367                    let line = self
368                        .defaults
369                        .get(index)
370                        .cloned()
371                        .unwrap_or_else(|| self.lines[index - 1].clone());
372                    self.lines.push(line);
373                }
374            }
375            ("directional", InputValue::Toggle(directional)) => self.directional = directional,
376            ("cross_first", InputValue::Choice(pick)) => self.cross[0] = pick,
377            ("cross_second", InputValue::Choice(pick)) => self.cross[1] = pick,
378            (id, value) => {
379                let (field, index) = id
380                    .rsplit_once('_')
381                    .ok_or_else(|| crate::studies::StudyError::UnknownInput(id.into()))?;
382                let index: usize = index
383                    .parse()
384                    .map_err(|_| crate::studies::StudyError::UnknownInput(id.into()))?;
385                let line = self
386                    .lines
387                    .get_mut(index)
388                    .ok_or_else(|| crate::studies::StudyError::UnknownInput(id.into()))?;
389                match (field, value) {
390                    ("period", InputValue::Length(period)) => line.period = period,
391                    ("kind", InputValue::Choice(kind)) => {
392                        line.kind = [
393                            AverageType::Simple,
394                            AverageType::Exponential,
395                            AverageType::Weighted,
396                            AverageType::Smoothed,
397                            AverageType::VolumeWeighted,
398                            AverageType::Hull,
399                        ][kind];
400                        line.period =
401                            line.period
402                                .max(if line.kind == AverageType::Hull { 2 } else { 1 });
403                    }
404                    ("source", InputValue::Choice(source)) => {
405                        line.source = PriceSource::all()[source]
406                    }
407                    _ => return Err(crate::studies::StudyError::UnknownInput(id.into())),
408                }
409            }
410        }
411        self.calculate(&[]);
412        Ok(())
413    }
414
415    fn name(&self) -> &str {
416        "MA Combination"
417    }
418    fn desc(&self) -> &str {
419        "Independently configured moving averages and crossing markers"
420    }
421    fn calculate(&mut self, data: &[Bar]) {
422        self.update(data, 0);
423    }
424    fn update(&mut self, data: &[Bar], from: usize) {
425        let len = self.values.len();
426        let start = if len > 0 && from >= len - 1 && data.len() >= len {
427            from.min(len)
428        } else {
429            0
430        };
431        if start == 0 {
432            self.averages = self
433                .lines
434                .iter()
435                .map(|line| line.kind.build(line.period))
436                .collect();
437            self.sourced = vec![Vec::new(); self.lines.len()];
438        }
439        for ((line, average), sourced) in self
440            .lines
441            .iter()
442            .zip(&mut self.averages)
443            .zip(&mut self.sourced)
444        {
445            if line.source == PriceSource::Close {
446                average.update(data, start);
447            } else {
448                sourced.truncate(start);
449                sourced.extend(data[start..].iter().map(|bar| {
450                    let mut bar = bar.clone();
451                    bar.close = line.source.compute(bar.open, bar.high, bar.low, bar.close);
452                    bar
453                }));
454                average.update(sourced, start);
455            }
456        }
457        let pair = self.crossing_pair();
458        self.markers
459            .truncate(self.markers.partition_point(|marker| marker.bar < start));
460        self.crossing
461            .run(data, start, &mut self.values, |state, _bar| {
462                let i = state.0;
463                state.0 += 1;
464                let lines: Vec<_> = self
465                    .averages
466                    .iter()
467                    .map(|average| average.values().value(0, i).unwrap_or(f64::NAN))
468                    .collect();
469                if lines.iter().all(|v| !v.is_finite())
470                    || (self.wait_for_all && lines.iter().any(|v| !v.is_finite()))
471                {
472                    state.1 = None;
473                    return IndicatorValue::None;
474                }
475                if let Some((first, second)) = pair {
476                    if lines[first].is_finite() && lines[second].is_finite() {
477                        let direction = lines[first].partial_cmp(&lines[second]).unwrap();
478                        if direction != std::cmp::Ordering::Equal {
479                            if state.1.is_some_and(|p| p != direction) {
480                                self.markers.push(IndicatorMarker {
481                                    bar: i,
482                                    value: lines[first],
483                                    direction: if direction == std::cmp::Ordering::Greater {
484                                        CrossDirection::Up
485                                    } else {
486                                        CrossDirection::Down
487                                    },
488                                });
489                            }
490                            state.1 = Some(direction);
491                        }
492                    } else {
493                        state.1 = None;
494                    }
495                }
496                IndicatorValue::Multiple(lines)
497            });
498    }
499    fn is_incremental(&self) -> bool {
500        true
501    }
502    fn values(&self) -> &IndicatorValues {
503        &self.values
504    }
505    fn colors(&self) -> Vec<Color32> {
506        self.lines.iter().map(|line| line.color).collect()
507    }
508    fn set_colors(&mut self, colors: Vec<Color32>) {
509        for (line, color) in self.lines.iter_mut().zip(colors) {
510            line.color = color;
511        }
512    }
513    fn line_names(&self) -> Vec<String> {
514        self.lines
515            .iter()
516            .map(|line| {
517                format!(
518                    "{}({})",
519                    line.kind.label(),
520                    line.period
521                        .max(if line.kind == AverageType::Hull { 2 } else { 1 })
522                )
523            })
524            .collect()
525    }
526    fn markers(&self) -> &[IndicatorMarker] {
527        &self.markers
528    }
529    fn line_style(&self, line: usize) -> IndicatorLineStyle {
530        match self.lines.get(line) {
531            Some(average) if self.directional => {
532                let slowest = line + 1 == self.lines.len();
533                IndicatorLineStyle {
534                    width: if slowest { 3.0 } else { 1.0 },
535                    rising: Some(average.color),
536                    falling: Some(Color32::from_rgb(38, 198, 218)),
537                    fill: slowest.then_some(IndicatorFill {
538                        color: Color32::from_rgb(98, 55, 174),
539                        top_alpha: 38,
540                        bottom_alpha: 10,
541                    }),
542                }
543            }
544            _ => IndicatorLineStyle::default(),
545        }
546    }
547
548    fn is_visible(&self) -> bool {
549        self.visible
550    }
551    fn set_visible(&mut self, visible: bool) {
552        self.visible = visible;
553    }
554    fn clone_box(&self) -> Box<dyn Indicator> {
555        Box::new(self.clone())
556    }
557}
558
559// Preserve the existing public preset types and their constructors while sharing all calculation.
560macro_rules! impl_ma_preset {
561    ($ty:ident, $name:literal, $description:literal $(, $marker:expr)?) => {
562        impl crate::studies::Indicator for $ty {
563            fn inputs(&self) -> Vec<crate::studies::StudyInput> {
564                self.inner.preset_inputs(matches!($name, "MA Ribbon"))
565            }
566            fn set_input(
567                &mut self,
568                id: &str,
569                value: crate::studies::InputValue,
570            ) -> Result<(), crate::studies::StudyError> {
571                let schema = self
572                    .inputs()
573                    .into_iter()
574                    .find(|input| input.id == id)
575                    .ok_or_else(|| crate::studies::StudyError::UnknownInput(id.into()))?;
576                if !schema.accepts(&value) {
577                    return Err(crate::studies::StudyError::invalid_input(id));
578                }
579                self.inner.set_input(id, value)
580            }
581            fn name(&self) -> &str {
582                $name
583            }
584            fn desc(&self) -> &str {
585                $description
586            }
587            fn calculate(&mut self, data: &[crate::model::Bar]) {
588                self.inner.calculate(data);
589            }
590            fn update(&mut self, data: &[crate::model::Bar], from: usize) {
591                self.inner.update(data, from);
592            }
593            fn is_incremental(&self) -> bool {
594                self.inner.is_incremental()
595            }
596            fn values(&self) -> &crate::studies::IndicatorValues {
597                self.inner.values()
598            }
599            fn colors(&self) -> Vec<egui::Color32> {
600                self.inner.colors()
601            }
602            fn set_colors(&mut self, colors: Vec<egui::Color32>) {
603                if colors.len() >= self.inner.line_count() {
604                    self.inner.set_colors(colors);
605                }
606            }
607            fn line_names(&self) -> Vec<String> {
608                let mut names = self.inner.line_names();
609                if matches!($name, "MA Cross" | "EMA Cross") {
610                    names[0] = format!("Fast {}", names[0]);
611                    names[1] = format!("Slow {}", names[1]);
612                }
613                names
614            }
615            $(fn marker_style(&self) -> Option<crate::studies::PlotStyle> {
616                Some(crate::studies::PlotStyle {
617                    color: $marker, width: 4.0,
618                    pattern: crate::model::LineStyle::Solid,
619                    plot_type: crate::studies::PlotType::Crosses, visible: true,
620                })
621            })?
622            fn markers(&self) -> &[crate::studies::IndicatorMarker] {
623                self.inner.markers()
624            }
625            fn line_style(&self, line: usize) -> crate::studies::IndicatorLineStyle {
626                self.inner.line_style(line)
627            }
628            fn is_visible(&self) -> bool {
629                self.inner.is_visible()
630            }
631            fn set_visible(&mut self, visible: bool) {
632                self.inner.set_visible(visible);
633            }
634            fn clone_box(&self) -> Box<dyn crate::studies::Indicator> {
635                Box::new(self.clone())
636            }
637        }
638    };
639}
640pub(super) use impl_ma_preset;
641
642#[cfg(test)]
643mod tests {
644    use crate::model::Bar;
645    use crate::studies::test_support::make_bar;
646    use crate::studies::{IndicatorFactory, InputKind, InputValue};
647
648    /// Closes rise from 0 to 59, then fall from 60 to 1. At bar 59 the 9-bar mean (55) is above the
649    /// 21-bar mean (49) and the 20-bar (49.5) above the 50-bar (34.5); at bar 119 the 9-bar (5) is
650    /// below the 21-bar (11) and the 20-bar (10.5) below the 50-bar (25.5). So the first two
651    /// averages of every preset cross.
652    #[test]
653    fn presets_offer_their_crossing_pair_with_none() {
654        use InputValue::Choice;
655        let bars: Vec<Bar> = (0..60)
656            .chain((1..=60).rev())
657            .map(|close| make_bar(f64::from(close)))
658            .collect();
659        for (name, averages, marked) in [
660            ("MA Cross", 2, true),
661            ("EMA Cross", 2, true),
662            ("SMA/EMA Cross", 2, true),
663            ("Triple MA", 3, true),
664            ("Multi MA", 4, false),
665            ("MA Ribbon", 12, false),
666        ] {
667            let mut study = IndicatorFactory::new().create(name).unwrap();
668            let inputs = study.inputs();
669            for id in ["cross_first", "cross_second"] {
670                let input = inputs.iter().find(|input| input.id == id).unwrap();
671                let InputKind::Choice(picks) = &input.kind else {
672                    panic!("{name} {id}: {:?}", input.kind)
673                };
674                assert_eq!(
675                    (picks.len(), picks[0].as_str()),
676                    (averages + 1, "None"),
677                    "{name}"
678                );
679            }
680            study.calculate(&bars);
681            assert_eq!(!study.markers().is_empty(), marked, "{name} by default");
682            study.set_input("cross_first", Choice(1)).unwrap();
683            study.set_input("cross_second", Choice(2)).unwrap();
684            study.calculate(&bars);
685            assert!(!study.markers().is_empty(), "{name} with its first two");
686            study.set_input("cross_first", Choice(0)).unwrap();
687            study.calculate(&bars);
688            assert!(study.markers().is_empty(), "{name} with None");
689            assert!(
690                study
691                    .set_input("cross_first", Choice(averages + 1))
692                    .is_err(),
693                "{name} past its averages"
694            );
695        }
696    }
697}