1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum AverageType {
15 Simple,
17 Exponential,
19 Weighted,
21 Smoothed,
23 VolumeWeighted,
25 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#[derive(Clone, Debug)]
55pub struct AverageLine {
56 pub kind: AverageType,
58 pub period: usize,
60 pub source: PriceSource,
62 pub color: Color32,
64}
65
66impl AverageLine {
67 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 pub fn with_source(mut self, source: PriceSource) -> Self {
79 self.source = source;
80 self
81 }
82}
83
84const MAX_AVERAGES: usize = 14;
86
87fn 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#[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 cross: [usize; 2],
118 default_cross: [usize; 2],
119 pub(super) wait_for_all: bool,
120 directional: bool,
122 default_directional: bool,
123 visible: bool,
124}
125
126impl MACombination {
127 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 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 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 pub(super) fn without_crossings(mut self) -> Self {
186 self.cross = [0, 0];
187 self.default_cross = [0, 0];
188 self
189 }
190
191 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 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 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 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 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
559macro_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 #[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}